Modules/ScubaConfigApp/ScubaConfigAnalyzerHelpers/ScubaConfigAnalyzerUIHelper.psm1
|
<#
.SYNOPSIS UI wiring + multithreaded orchestration for the ScubaGear Config Analyzer window .DESCRIPTION This module is imported INSIDE the analyzer's STA runspace. It: - Populates the toolbar controls and wires every UI event. - Runs the analysis on a background runspace (never on the UI dispatcher) and marshals the result back with a DispatcherTimer, so the window stays responsive while a large tenant is analyzed. - Runs ScubaGear in a separate, visible process (so interactive Graph auth works) and polls for completion, then analyzes the fresh export. - Renders per-control findings, best-match policy, remediation steps and the editable exclusion lists, and keeps the per-control + aggregate YAML in sync. All shared state lives on the synchronized $syncHash so DispatcherTimer ticks and event handlers can reach it after the launching function has returned. ---------------------------------------------------------------------------------- HOW THIS FILE FITS TOGETHER (read this first) ---------------------------------------------------------------------------------- There are two modules behind the analyzer window: - ScubaConfigAnalyzerEngine.psm1 -> pure logic, NO WPF. Reads ScubaResults / tenant Graph data and returns plain objects. Unit-testable headlessly. - ScubaConfigAnalyzerUIHelper.psm1 -> THIS file. All the WPF/UI code. It calls the engine, then pushes the returned objects into the window's controls. The engine never touches the UI; the UI never re-implements analysis logic. That is the whole reason the code is in two files. Control flow when the user clicks "Connect & Scan": Start-ScubaAnalyzerTenantScan -> Connect-ScubaAnalyzerGraph (engine, on UI thread so sign-in works) -> Get-ScubaTenantGraphData (engine, reads CA policies from Graph) -> Invoke-ScubaTenantScan (engine, on a BACKGROUND runspace) -> Update-ScubaAnalyzerFindings (fills the findings list + header) -> Update-ScubaAnalyzerFullYaml (regenerates the aggregate YAML) "Load results file" is the same, minus Graph: Start-ScubaAnalyzerAnalysis instead. ---------------------------------------------------------------------------------- THE BACKGROUND-RUNSPACE PATTERN (used 3x below - this is the confusing bit) ---------------------------------------------------------------------------------- WPF has one UI thread. If we run a multi-second analysis on it, the window freezes. So the heavy work runs on a second runspace and the answer is handed back like this: 1. Make a synchronized hashtable as the "mailbox": @{ IsComplete = $false; Result = $null; Error = $null } 2. Create an MTA runspace, copy in the values the worker needs (SessionStateProxy.SetVariable), and BeginInvoke a script that: imports the engine, does the work, writes Result/Error, sets IsComplete. 3. Start a DispatcherTimer on the UI thread that ticks every ~200ms and does nothing until IsComplete is $true. When it flips true it: stops the timer, EndInvoke + disposes the runspace, then reads Result/Error and updates the UI. The DispatcherTimer tick runs BACK on the UI thread, so it is safe to touch controls there. The worker script must never touch $syncHash controls directly. ---------------------------------------------------------------------------------- DATA CONTRACTS (shapes handed around - so you can predict what a variable holds) ---------------------------------------------------------------------------------- $syncHash.Analysis (returned by the engine; also what Invoke-ScubaTenantScan returns): MetaData = @{ DisplayName; Organization; TenantId; ScanDate; ResultsPath } Summary = @{ Passes; Failures; Warnings; Errors; Manual; Total; ComplianceRate } Products = @('aad', ...) Findings = @( <Finding>, ... ) # one per analyzed control DisplayNameLookup = @{ '<object id>' = '<display name>' } <Finding> (one control - drives one row in the findings list + the detail pane): ControlId = 'MS.AAD.3.1v1' ProductConfigKey = 'Aad' # top-level YAML key for this product Requirement = 'Legacy authentication SHALL be blocked.' Result = 'Fail' | 'Warning' | 'Pass' | 'Error' | 'Manual' Configurable = $true # can config (exclusions) make it pass? ExclusionField = 'CapExclusions' | 'none' ConfigAction = 'EXCLUDE' | 'FIX_TENANT' | ... DetectedExclusions = @{ Users=@(); Groups=@(); Applications=@(); GuestUserTypes=@() } AllPolicies = @( <PolicyCandidate>, ... ) # matching CA policies BestMatch = <PolicyCandidate> | $null # closest to compliant SelectedPolicyId = '<policy id>' # which candidate the YAML follows RootCause, Recommendations, RemediationSteps, Details, Criticality, ... <PolicyCandidate> (one Conditional Access policy that relates to the control): Id, DisplayName, State Issues = @( 'WARNING: <msg>|DETAILS:<k>: <v>|SUGGESTION:<text>', ... ) IssueCount, DetectedExclusions = @{ Users; Groups; Applications; GuestUserTypes } Key $syncHash UI handles used below: Window, Environment_ComboBox, Product_ListBox, Findings_List, Detail_* (detail pane controls), FullYaml_TextBox, Run_Button, Run_Progress, RunOutput_TextBox, TenantText / ConnectionText / ScanDateText (header). Launch params also on $syncHash: AppId, CertificateThumbprint, Organization, M365Environment, and the schema paths (AnalyzerEnginePath, BaselineSchemaPath, ...). .NOTES Part of the ScubaGear project - https://github.com/cisagov/ScubaGear #> # ------------------------------------------------------------------------------------ # Small helpers # ------------------------------------------------------------------------------------ function Get-ScubaAnalyzerText { <# .SYNOPSIS Returns a localized status-bar string from the control JSON (localeStatusMessages), applying -f formatting. Falls back to the key name if the locale is missing so nothing is silently blank. #> param( [Parameter(Mandatory)][string]$Key, [object[]]$FormatArgs ) $text = $null try { $text = [string]$syncHash.Locale.localeStatusMessages.$Key } catch { $text = $null } if ([string]::IsNullOrEmpty($text)) { return $Key } if ($FormatArgs) { return ($text -f $FormatArgs) } return $text } function Set-ScubaAnalyzerStatus { <# .SYNOPSIS Writes a one-line message to the status bar at the bottom of the window. .DESCRIPTION Safe to call from ANY thread: it marshals the update onto the UI thread via the window dispatcher (WPF controls may only be touched from the thread that owns them). Wrapped in try/catch so a status update can never crash the operation behind it. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Message', Justification = 'Used inside the dispatcher [Action] scriptblock, which the analyzer does not inspect.')] param([string]$Message) try { # Only attempt the update once the window + status control actually exist. if ($syncHash.Window -and $syncHash.Status_Text) { # Dispatcher.Invoke hops onto the UI thread before setting the text. $syncHash.Window.Dispatcher.Invoke([Action] { $syncHash.Status_Text.Text = $Message }) } } catch { Write-ScubaAnalyzerLog "Set-ScubaAnalyzerStatus failed: $($_.Exception.Message)" -Level Error } } function Write-ScubaAnalyzerLog { <# .SYNOPSIS Appends a timestamped line to the "Run Output" activity log (and records it for -Passthru). .DESCRIPTION Mirrors ScubaConfigApp's Write-DebugOutput: records the entry in $syncHash.LogEntries and writes the Activity Log directly via the window dispatcher. -Level tags Warning/Error lines. .EXAMPLE Write-ScubaAnalyzerLog "Connected as admin@contoso (tenant 1234)." .EXAMPLE Write-ScubaAnalyzerLog "Graph read failed: ..." -Level Error #> param( [string]$Message, [ValidateSet('Info','Warning','Error')] [string]$Level = 'Info' ) try { $prefix = if ($Level -ne 'Info') { "[$($Level.ToUpper())] " } else { "" } $line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Message" if ($null -ne $syncHash.LogEntries) { [void]$syncHash.LogEntries.Add([pscustomobject]@{ Time = Get-Date; Level = $Level; Message = $Message }) } if ($syncHash.RunOutput_TextBox) { $syncHash.Window.Dispatcher.Invoke([Action] { $syncHash.RunOutput_TextBox.AppendText("$line`r`n") $syncHash.RunOutput_TextBox.ScrollToEnd() # keep the newest line visible }) } } catch { Write-ScubaAnalyzerLog "Write-ScubaAnalyzerLog failed: $($_.Exception.Message)" -Level Error } } function Format-ScubaAnalyzerIssues { <# .SYNOPSIS Turns the engine's raw pipe-delimited issue strings into readable bullet text for the policy cards. .DESCRIPTION Each issue string the engine emits has up to three sections separated by '|': WARNING: <headline>|DETAILS:<key>: <values>|SUGGESTION:<what to do> This keeps the headline, appends the DETAILS in parentheses, and drops the WARNING:/ERROR: prefix and the SUGGESTION section (shown elsewhere). .EXAMPLE Format-ScubaAnalyzerIssues -Issues @( 'WARNING: Policy has 2 excluded user(s)|DETAILS:IDs: 11.., 22..|SUGGESTION:review' ) # Returns: # - Policy has 2 excluded user(s) (IDs: 11.., 22..) #> param([array]$Issues = @()) $lines = @() foreach ($issue in @($Issues)) { if ([string]::IsNullOrWhiteSpace($issue)) { continue } $parts = $issue -split '\|' # [0]=headline [1]=DETAILS [2]=SUGGESTION $main = $parts[0] -replace '^(WARNING|ERROR):\s*', '' # strip the severity prefix $detail = @($parts | Where-Object { $_ -like 'DETAILS:*' }) -replace '^DETAILS:', '' $line = "- $main" if ($detail) { $line += " ($(($detail -join ' ').Trim()))" } # tack DETAILS on in parens $lines += $line } return ($lines -join "`n") } # ------------------------------------------------------------------------------------ # YAML helpers (per-control + aggregate), driven by the detected exclusions # ------------------------------------------------------------------------------------ function New-ScubaAnalyzerControlYamlText { <# .SYNOPSIS Builds the YAML snippet shown in the detail pane for ONE control (the "Control YAML" box), from that control's currently-selected exclusions. .DESCRIPTION This is the per-control preview only. The aggregate file for the whole tenant is built separately by the engine's Get-ScubaAnalyzerConfigYaml (see Update-ScubaAnalyzerFullYaml). Both intentionally produce the same layout. Controls whose ExclusionField is 'none' cannot be waived by config, so this returns an explanatory comment instead of a config block. .EXAMPLE # For MS.AAD.3.1v1 with two excluded users, returns text like: # Aad: # # Legacy authentication SHALL be blocked. # # CA policy: Block legacy auth # MS.AAD.3.1v1: # CapExclusions: # Users: # - 1111.... # Break Glass 1 # - 2222.... # Break Glass 2 #> param( [Parameter(Mandatory)]$Finding, $ExclusionValues = @{}, # field name -> @(values); covers principal AND list shapes [hashtable]$DisplayNameLookup = @{} # object id -> friendly name, for '# name' comments ) # Controls that don't support config exclusions get a comment, not a config block. if (-not $Finding.ExclusionField -or $Finding.ExclusionField -eq 'none') { return "# $($Finding.ControlId) does not support exclusions.`n# Remediate by creating or fixing the policy (see remediation steps)." } # Resolve the name of the CA policy the exclusions are based on (the user's selected # candidate if any, otherwise the best match) - emitted as a '# CA policy:' comment. $selId = [string]$Finding.SelectedPolicyId $selPol = @($Finding.AllPolicies | Where-Object { [string]$_.Id -eq $selId }) | Select-Object -First 1 $caName = if ($selPol) { $selPol.DisplayName } elseif ($Finding.BestMatch) { $Finding.BestMatch.DisplayName } else { $null } $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine("$($Finding.ProductConfigKey):") [void]$sb.AppendLine(" # $($Finding.Requirement)") if ($caName) { [void]$sb.AppendLine(" # CA policy: $caName") } [void]$sb.AppendLine(" $($Finding.ControlId):") [void]$sb.AppendLine(" $($Finding.ExclusionField):") # Principal-shape types (CapExclusions/RoleExclusions) expose named fields; list-shape # types (AllowedForwardingDomains, PartnerDomains, ...) have none and render as a flat list. $fieldDefs = @(Get-ScAExclusionFieldDefinitions -ExclusionField $Finding.ExclusionField) $didEmit = $false if (@($fieldDefs).Count -gt 0) { foreach ($fieldDef in $fieldDefs) { $fieldName = if ($fieldDef.value) { [string]$fieldDef.value } else { [string]$fieldDef.name } $fieldList = if ($ExclusionValues.Contains($fieldName)) { @($ExclusionValues[$fieldName]) } else { @() } if (@($fieldList).Count -eq 0) { continue } $didEmit = $true [void]$sb.AppendLine(" $($fieldName):") foreach ($item in @($fieldList)) { $key = [string]$item $comment = if ($DisplayNameLookup.ContainsKey($key)) { " # $($DisplayNameLookup[$key])" } else { "" } [void]$sb.AppendLine(" - $item$comment") } } if (-not $didEmit) { $hint = @($fieldDefs | ForEach-Object { $_.value }) -join '/' [void]$sb.AppendLine(" # No exclusions. Add $hint only if justified.") } } else { $list = if ($ExclusionValues.Contains($Finding.ExclusionField)) { @($ExclusionValues[$Finding.ExclusionField]) } else { @() } if (@($list).Count -gt 0) { $didEmit = $true foreach ($v in @($list)) { $key = [string]$v $comment = if ($DisplayNameLookup.ContainsKey($key)) { " # $($DisplayNameLookup[$key])" } else { "" } [void]$sb.AppendLine(" - $v$comment") } } else { [void]$sb.AppendLine(" # No entries detected. Add approved $($Finding.ExclusionField) here.") } } return $sb.ToString() } function Update-ScubaAnalyzerControlYaml { <# .SYNOPSIS Refreshes the detail pane's per-control YAML box (Detail_Yaml) for the given finding. .DESCRIPTION Pulls the finding's current DetectedExclusions (which change when the user picks a different candidate policy via Select-ScubaAnalyzerPolicy) and re-renders the snippet. Called whenever the selected finding or its chosen policy changes. #> param([Parameter(Mandatory)]$Finding) try { # Build one schema-keyed value map covering both principal and list exclusion shapes. $vals = [ordered]@{} if ($Finding.DetectedExclusions) { foreach ($k in @($Finding.DetectedExclusions.Keys)) { $v = $Finding.DetectedExclusions[$k] if ($null -ne $v -and @($v).Count -gt 0) { $vals[$k] = @($v) } } } if ($Finding.DetectedExclusionValues) { foreach ($k in @($Finding.DetectedExclusionValues.Keys)) { if (-not $vals.Contains($k) -and @($Finding.DetectedExclusionValues[$k]).Count -gt 0) { $vals[$k] = @($Finding.DetectedExclusionValues[$k]) } } } # DisplayNameLookup lets the snippet annotate each id with '# Friendly Name'. $lookup = if ($syncHash.Analysis -and $syncHash.Analysis.DisplayNameLookup) { $syncHash.Analysis.DisplayNameLookup } else { @{} } $syncHash.Detail_Yaml.Text = New-ScubaAnalyzerControlYamlText -Finding $Finding -ExclusionValues $vals -DisplayNameLookup $lookup } catch { Write-ScubaAnalyzerLog "Update-ScubaAnalyzerControlYaml failed: $($_.Exception.Message)" -Level Error } } function Update-ScubaAnalyzerFullYaml { <# .SYNOPSIS Regenerates the aggregate configuration YAML (the "Configuration YAML" tab) for the whole tenant and pushes it into FullYaml_TextBox. .DESCRIPTION Delegates to the engine's Get-ScubaAnalyzerConfigYaml so the UI never re-implements YAML formatting. Call this after every change that can affect the file: a completed scan/analysis, a policy switch, or an environment-dropdown change. The selected environment and the launch-time app-only creds (AppId/Thumbprint) are folded into the generated header. #> try { if (-not $syncHash.Analysis) { return } # nothing analyzed yet -> nothing to build # Environment drives the 'M365Environment:' line; default to commercial if unset. $env = if ($syncHash.Environment_ComboBox.SelectedItem) { [string]$syncHash.Environment_ComboBox.SelectedItem } else { 'commercial' } $lookup = if ($syncHash.Analysis.DisplayNameLookup) { $syncHash.Analysis.DisplayNameLookup } else { @{} } $syncHash.FullYaml_TextBox.Text = Get-ScubaAnalyzerConfigYaml -Analysis $syncHash.Analysis -M365Environment $env -DisplayNameLookup $lookup -AppId $syncHash.AppId -CertificateThumbprint $syncHash.CertificateThumbprint -Organization $syncHash.Organization } catch { Write-ScubaAnalyzerLog "Failed to build configuration YAML: $($_.Exception.Message)" -Level Error } } # ------------------------------------------------------------------------------------ # Findings list + detail rendering # ------------------------------------------------------------------------------------ function Update-ScubaAnalyzerFindings { <# .SYNOPSIS Applies the toolbar filters (Issues-only, Configurable-only, search) and (re)builds the findings list + the header/summary line. .DESCRIPTION Reads the whole analysis from $syncHash.Analysis, updates the header (tenant, scan date, pass/fail counts), then binds the FILTERED subset of findings to the list. Call this after a scan/analysis completes and whenever a filter control changes. Setting Findings_List.ItemsSource is what actually repaints the list. #> if (-not $syncHash.Analysis) { return } $a = $syncHash.Analysis # --- Header + summary line ------------------------------------------------------- # Prefer the tenant's primary domain (Organization); fall back to display name. $tenantLabel = if ($a.MetaData.Organization) { $a.MetaData.Organization } elseif ($a.MetaData.DisplayName) { $a.MetaData.DisplayName } else { '(unknown)' } $syncHash.TenantText.Text = "Tenant: $tenantLabel" $syncHash.ScanDateText.Text = if ($a.MetaData.ScanDate) { "Scan: $($a.MetaData.ScanDate)" } else { "" } $s = $a.Summary # "Need attention" = anything not a clean Pass. $needAttention = @($a.Findings | Where-Object { $_.Result -ne 'Pass' }).Count $syncHash.SummaryText.Text = "$($s.Total) controls, $needAttention need attention" $syncHash.ComplianceText.Text = "Pass $($s.Passes) | Fail $($s.Failures) | Warn $($s.Warnings) | Error $($s.Errors) | Manual $($s.Manual) | $($s.ComplianceRate)% compliant" # --- Read the three filter controls ---------------------------------------------- $issuesOnly = [bool]$syncHash.IssuesOnly_CheckBox.IsChecked # hide clean passes # ConfigurableOnly is optional (guard in case the XAML predates it). $configurableOnly = if ($syncHash.ContainsKey('ConfigurableOnly_CheckBox') -and $syncHash.ConfigurableOnly_CheckBox) { [bool]$syncHash.ConfigurableOnly_CheckBox.IsChecked } else { $false } $search = if ($syncHash.Search_TextBox.Text) { $syncHash.Search_TextBox.Text.Trim() } else { "" } # --- Apply all active filters (AND-combined) ------------------------------------- $filtered = @($a.Findings | Where-Object { (-not $issuesOnly -or $_.Result -ne 'Pass') -and # Issues-only (-not $configurableOnly -or $_.Configurable) -and # Configurable-only ([string]::IsNullOrWhiteSpace($search) -or # search matches id OR requirement $_.ControlId -like "*$search*" -or $_.Requirement -like "*$search*") }) $syncHash.Findings_List.ItemsSource = $filtered # bind = repaint the list # If nothing matches, hide the detail pane and show a placeholder message. if (@($filtered).Count -eq 0) { $syncHash.DetailContent.Visibility = 'Collapsed' $syncHash.EmptyDetailText.Visibility = 'Visible' $syncHash.EmptyDetailText.Text = "No findings match the current filter." } } function Select-ScubaAnalyzerPolicy { <# .SYNOPSIS Switches the "in use" candidate policy for the selected finding. The chosen policy's detected exclusions become the finding's exclusions so both the per-control and the full configuration YAML follow the user's choice. .DESCRIPTION A single control can match several Conditional Access policies, each with its own set of excluded users/groups. The user clicks "Use this policy" on a card to pick which one the generated config should mirror; this copies that candidate's exclusions onto the finding, then re-renders the detail pane and the aggregate YAML. .PARAMETER PolicyId The Id of the candidate policy (from the card's button Tag) to adopt. #> param([Parameter(Mandatory)][string]$PolicyId) try { $finding = $syncHash.Findings_List.SelectedItem if (-not $finding) { return } # Find the chosen candidate among this finding's matching policies. $chosen = @($finding.AllPolicies | Where-Object { [string]$_.Id -eq $PolicyId }) | Select-Object -First 1 if (-not $chosen) { return } $finding.SelectedPolicyId = $PolicyId # Copy the candidate's exclusions onto the finding (fresh arrays so later edits # don't mutate the candidate). This is what the YAML builders read. $finding.DetectedExclusions = Get-ScAExclusionValueMap -ExclusionField $finding.ExclusionField -Values $chosen.DetectedExclusions # Keep the schema-keyed value map in sync so both YAML views follow the choice. $dv = [ordered]@{} foreach ($k in @($finding.DetectedExclusions.Keys)) { if (@($finding.DetectedExclusions[$k]).Count -gt 0) { $dv[$k] = @($finding.DetectedExclusions[$k]) } } $finding.DetectedExclusionValues = $dv Show-ScubaAnalyzerDetail # re-render cards (updates which one is "in use") Update-ScubaAnalyzerFullYaml # aggregate YAML follows the new choice Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'UsingPolicy' @($chosen.DisplayName, $finding.ControlId)) } catch { Write-ScubaAnalyzerLog "Use policy failed: $($_.Exception.Message)" -Level Error } } function Show-ScubaAnalyzerDetail { <# .SYNOPSIS Populates the right-hand detail pane for the finding currently selected in the list. .DESCRIPTION Fired by the Findings_List SelectionChanged event. Fills every Detail_* control: the header (control id, result badge, requirement, group, root cause, ScubaGear details), the recommendations, the matching-policy cards, the remediation steps, and finally the per-control YAML box. Each optional section is shown or Collapsed based on whether it has content, so empty sections don't leave gaps. #> try { $finding = $syncHash.Findings_List.SelectedItem # Nothing selected -> hide the detail pane, show the placeholder, and bail. if (-not $finding) { $syncHash.DetailContent.Visibility = 'Collapsed' $syncHash.EmptyDetailText.Visibility = 'Visible' return } $syncHash.EmptyDetailText.Visibility = 'Collapsed' $syncHash.DetailContent.Visibility = 'Visible' # --- Header block ------------------------------------------------------------ $syncHash.Detail_ControlId.Text = $finding.ControlId $syncHash.Detail_ResultText.Text = $finding.Result $syncHash.Detail_ResultBadge.DataContext = $finding # drives badge colour via DataTrigger $syncHash.Detail_Requirement.Text = $finding.Requirement $groupLabel = @("$($finding.GroupNumber) $($finding.GroupName)".Trim(), "Criticality: $($finding.Criticality)") -join ' - ' $syncHash.Detail_GroupText.Text = $groupLabel $syncHash.Detail_RootCause.Text = $finding.RootCause $syncHash.Detail_Details.Text = "ScubaGear details: $($finding.Details)" $syncHash.Detail_Criticality.Text = "Action: $($finding.RequiresAction)" # --- Recommendations (show the list only if there are any) ------------------- $recs = @($finding.Recommendations) if (@($recs).Count -gt 0) { $syncHash.RecommendationsHeader.Visibility = 'Visible' $syncHash.Detail_Recommendations.Visibility = 'Visible' $syncHash.Detail_Recommendations.ItemsSource = $recs } else { $syncHash.RecommendationsHeader.Visibility = 'Collapsed' $syncHash.Detail_Recommendations.Visibility = 'Collapsed' $syncHash.Detail_Recommendations.ItemsSource = $null } # --- Matching-policy cards --------------------------------------------------- # Each card; one CA policy that relates to this control. The user can pick which # candidate the config follows ("Use this policy"); the selected one is expanded # and drives the YAML. We project each PolicyCandidate into a flat view-model with # pre-computed Visibility flags (WPF binds to these; it can't run logic itself). $policyItems = @() $best = $finding.BestMatch # Selected = the user's explicit choice, else the engine's best match. $selectedId = if ($finding.SelectedPolicyId) { [string]$finding.SelectedPolicyId } elseif ($best) { [string]$best.Id } else { $null } $multiple = (@($finding.AllPolicies).Count -gt 1) # only offer a choice when >1 foreach ($p in @($finding.AllPolicies)) { $isBest = ($best -and $p.Id -eq $best.Id) $isSelected = ($selectedId -and [string]$p.Id -eq $selectedId) # Turn the raw issue strings into readable bullets; if none, the policy is clean. $issuesText = Format-ScubaAnalyzerIssues -Issues @($p.Issues) if (-not $issuesText) { $issuesText = "This policy meets the baseline requirement - no changes needed." } # Offer 'Use this policy' on the OTHER candidates only when there's a real choice. $showUse = ($multiple -and -not $isSelected) $policyItems += [pscustomobject]@{ PolicyId = [string]$p.Id DisplayName = $p.DisplayName StateText = "State: $($p.State) - Issues: $($p.IssueCount)" IssuesText = $issuesText BestMatchVisibility = if ($isBest) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } InUseVisibility = if ($isSelected -and $multiple) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } UseButtonVisibility = if ($showUse) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } IssuesVisibility = [System.Windows.Visibility]::Visible IsExpanded = [bool]$isSelected # expand the in-use card } } if (@($policyItems).Count -gt 0) { $syncHash.PoliciesHeader.Visibility = 'Visible' $syncHash.Detail_Policies.Visibility = 'Visible' $syncHash.Detail_Policies.ItemsSource = $policyItems } else { $syncHash.PoliciesHeader.Visibility = 'Collapsed' $syncHash.Detail_Policies.Visibility = 'Collapsed' $syncHash.Detail_Policies.ItemsSource = $null } # --- Remediation steps (show only if the schema supplied any) ---------------- $steps = @($finding.RemediationSteps) if (@($steps).Count -gt 0) { $syncHash.RemediationHeader.Visibility = 'Visible' $syncHash.Detail_Remediation.Visibility = 'Visible' $syncHash.Detail_Remediation.ItemsSource = $steps } else { $syncHash.RemediationHeader.Visibility = 'Collapsed' $syncHash.Detail_Remediation.Visibility = 'Collapsed' $syncHash.Detail_Remediation.ItemsSource = $null } # --- Per-control YAML box (reflects the currently-selected policy's exclusions) Update-ScubaAnalyzerControlYaml -Finding $finding } catch { Write-ScubaAnalyzerLog "Failed to render finding: $($_.Exception.Message)" -Level Error } } # ------------------------------------------------------------------------------------ # Analysis (background runspace, polled by a DispatcherTimer) # ------------------------------------------------------------------------------------ function Start-ScubaAnalyzerAnalysis { <# .SYNOPSIS Analyzes an existing ScubaResults JSON (the "Load results file" path) on a background runspace so the UI never blocks. .DESCRIPTION This is the textbook version of the background-runspace pattern described in the file header. Follow the numbered STEP comments below to see the three phases: STEP 1 build the synchronized "mailbox" STEP 2 spin up the worker runspace and BeginInvoke the engine call STEP 3 poll the mailbox from the UI thread with a DispatcherTimer, then marshal the result back into the findings list + YAML. Use this when the data is already on disk; Start-ScubaAnalyzerTenantScan is the live Graph equivalent. .PARAMETER ResultsPath Path to a ScubaResults_*.json file to analyze. #> param([Parameter(Mandatory)][string]$ResultsPath) if (-not (Test-Path $ResultsPath)) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ResultsFileNotFound' $ResultsPath) return } # Which products to analyze = whatever is selected in the Products list (default aad). $products = @($syncHash.Product_ListBox.SelectedItems | ForEach-Object { [string]$_.Key }) if (@($products).Count -eq 0) { $products = @('aad') } # Show the indeterminate progress bar while the worker runs. $syncHash.Run_Progress.IsIndeterminate = $true $syncHash.Run_Progress.Visibility = 'Visible' # Loading a results file is NOT a live tenant connection - reflect that in the header. $syncHash.ConnectedTenant = $false if ($syncHash.ConnectionText) { $syncHash.ConnectionText.Text = 'Offline - results file' } Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'AnalyzingFile' (Split-Path $ResultsPath -Leaf)) Write-ScubaAnalyzerLog "Analyzing results: $ResultsPath (products: $($products -join ', '))" # STEP 1: the "mailbox" - a synchronized hashtable both threads can see. The worker # writes Result/Error + flips IsComplete; the UI timer only ever reads it. $analysisSync = [hashtable]::Synchronized(@{ IsComplete = $false; Result = $null; Error = $null }) $syncHash.AnalysisSync = $analysisSync # STEP 2: create the worker runspace and copy in the values it needs. It runs MTA # (no UI thread affinity) and gets only plain data + paths - never live WPF controls. $bgRunspace = [runspacefactory]::CreateRunspace() $bgRunspace.ApartmentState = "MTA" $bgRunspace.ThreadOptions = "ReuseThread" $bgRunspace.Open() $bgRunspace.SessionStateProxy.SetVariable("analysisSync", $analysisSync) # Share the REAL synchronized $syncHash (same as Start-ScubaAnalyzerTenantScan) so the engine's # Import-ScAAnalyzerRules populates the ScA* caches the UI thread later reads when rendering the # per-control YAML (Get-ScAExclusionFieldDefinitions). A separate worker cache left the UI thread's # ScAExclusionDefinitions empty, so imported findings rendered "No entries detected". $bgRunspace.SessionStateProxy.SetVariable("syncHash", $syncHash) $bgRunspace.SessionStateProxy.SetVariable("helpersPath", $syncHash.HelperModulesPath) $bgRunspace.SessionStateProxy.SetVariable("resultsPath", $ResultsPath) $bgRunspace.SessionStateProxy.SetVariable("product", $products) $bgRunspace.SessionStateProxy.SetVariable("baselineSchemaPath", $syncHash.BaselineSchemaPath) $bgRunspace.SessionStateProxy.SetVariable("AnalyzerControlPath", $syncHash.AnalyzerControlPath) $bgRunspace.SessionStateProxy.SetVariable("configSchemaPath", $syncHash.ConfigSchemaPath) $bgPS = [powershell]::Create() $bgPS.Runspace = $bgRunspace # The worker script: import the analyzer helper modules fresh (runspaces don't share module # state), run the analysis, and record the outcome in the mailbox. try/finally guarantees # IsComplete flips even on error, so the UI timer can never wait forever. [void]$bgPS.AddScript({ try { # Import every analyzer helper module (engine + helpers), excluding the UI helper. Get-ChildItem -Path $helpersPath -Filter '*.psm1' | Where-Object { $_.Name -ne 'ScubaConfigAnalyzerUIHelper.psm1' } | ForEach-Object { Import-Module $_.FullName -Force -ErrorAction Stop } $analysisSync.Result = Invoke-ScubaConfigAnalysis -ResultsPath $resultsPath -Product $product ` -BaselineSchemaPath $baselineSchemaPath -AnalyzerControlPath $AnalyzerControlPath -ConfigSchemaPath $configSchemaPath -IncludePassing } catch { $analysisSync.Error = $_.Exception.Message } finally { $analysisSync.IsComplete = $true } }) # BeginInvoke returns immediately; the work proceeds on the worker thread. Keep the # handles on $syncHash so the timer tick can EndInvoke + dispose them later. $syncHash.AnalysisBgPS = $bgPS $syncHash.AnalysisBgHandle = $bgPS.BeginInvoke() $syncHash.AnalysisBgRunspace = $bgRunspace # STEP 3: poll from the UI thread. The tick runs on the UI thread, so it is safe to # touch controls here. It does nothing until the worker flips IsComplete. $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(200) $timer.Add_Tick({ if (-not $syncHash.AnalysisSync.IsComplete) { return } # not done yet - wait for the next tick $syncHash.AnalysisTimer.Stop() # Drain + dispose the worker (each wrapped so one cleanup failure can't abort the rest). try { [void]$syncHash.AnalysisBgPS.EndInvoke($syncHash.AnalysisBgHandle) } catch { Write-Verbose "Analysis EndInvoke cleanup: $($_.Exception.Message)" } try { $syncHash.AnalysisBgPS.Dispose() } catch { Write-Verbose "Analysis PS dispose cleanup: $($_.Exception.Message)" } try { $syncHash.AnalysisBgRunspace.Close(); $syncHash.AnalysisBgRunspace.Dispose() } catch { Write-Verbose "Analysis runspace dispose cleanup: $($_.Exception.Message)" } $syncHash.Run_Progress.Visibility = 'Collapsed' $syncHash.Run_Progress.IsIndeterminate = $false # If the worker recorded an error, surface it and stop. if ($syncHash.AnalysisSync.Error) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'AnalysisFailed' $syncHash.AnalysisSync.Error) Write-ScubaAnalyzerLog "Analysis failed: $($syncHash.AnalysisSync.Error)" -Level Error return } # Success: publish the result and repaint the findings list + aggregate YAML. $syncHash.Analysis = $syncHash.AnalysisSync.Result Update-ScubaAnalyzerFindings Update-ScubaAnalyzerFullYaml Update-ScubaAnalyzerTenantGovernanceJson $count = @($syncHash.Analysis.Findings | Where-Object { $_.Result -ne 'Pass' }).Count Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'AnalysisComplete' $count) Write-ScubaAnalyzerLog "Analysis complete." }) $syncHash.AnalysisTimer = $timer $timer.Start() } # ------------------------------------------------------------------------------------ # ScubaGear run (separate visible process, polled by a DispatcherTimer) # ------------------------------------------------------------------------------------ function Start-ScubaAnalyzerTenantScan { <# .SYNOPSIS Connects to Microsoft Graph + Exchange Online, reads the live tenant, and validates it against the baselines - all on a BACKGROUND runspace - marshaling status/findings back via a DispatcherTimer so the UI never freezes during the (blocking) sign-in and Graph reads. .DESCRIPTION The "Connect & Scan" button handler. The UI thread only flips the button/progress and starts the worker; every blocking step runs off-thread: Worker - Connect Graph (A), connect Exchange Online if needed (A2), read tenant data (B), then validate against the baselines (C). It reports progress + the result through the synchronized $connectSync mailbox and never touches WPF controls. UI tick - a DispatcherTimer drains $connectSync (status, log lines, header) and, on completion, repaints the findings + YAML and re-enables the button. #> try { # Products + environment come straight from the toolbar selections. $products = @($syncHash.Product_ListBox.SelectedItems | ForEach-Object { [string]$_.Key }) if (@($products).Count -eq 0) { $products = @('aad') } $env = if ($syncHash.Environment_ComboBox.SelectedItem) { [string]$syncHash.Environment_ComboBox.SelectedItem } else { 'commercial' } # App-only = both an AppId and a cert thumbprint were supplied at launch. $appOnly = [bool]($syncHash.AppId -and $syncHash.CertificateThumbprint) # UI-thread setup only (fast): disable the button, show progress, jump to Run Output. $syncHash.Run_Button.IsEnabled = $false $syncHash.Run_Progress.IsIndeterminate = $true $syncHash.Run_Progress.Visibility = 'Visible' $syncHash.MainTabs.SelectedItem = $syncHash.ActivityLogTab $syncHash.LastConnectStatus = $null Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText $(if ($appOnly) { 'ConnectingGraphAppOnly' } else { 'ConnectingGraphInteractive' })) # Mailbox: the worker writes Status/Log/connection info/Result; the UI timer only reads it. $connectSync = [hashtable]::Synchronized(@{ IsComplete = $false; Error = $null; Result = $null; Status = $null Log = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) Connected = $false; HeaderApplied = $false ConnMode = $null; ConnWho = $null; Environment = $null; TenantLabel = $null }) $syncHash.ConnectSync = $connectSync # Background runspace. It shares the real (synchronized) $syncHash for the ScA caches, paths # and launch params, imports the analyzer helpers (not the UI helper), and runs A -> C. $bg = [runspacefactory]::CreateRunspace(); $bg.ApartmentState = 'MTA'; $bg.ThreadOptions = 'ReuseThread'; $bg.Open() $bg.SessionStateProxy.SetVariable('syncHash', $syncHash) $bg.SessionStateProxy.SetVariable('connectSync', $connectSync) $bg.SessionStateProxy.SetVariable('helpersPath', $syncHash.HelperModulesPath) $bg.SessionStateProxy.SetVariable('products', $products) $bg.SessionStateProxy.SetVariable('env', $env) $bg.SessionStateProxy.SetVariable('appOnly', $appOnly) $bgPS = [powershell]::Create(); $bgPS.Runspace = $bg [void]$bgPS.AddScript({ try { Get-ChildItem -Path $helpersPath -Filter '*.psm1' | Where-Object { $_.Name -ne 'ScubaConfigAnalyzerUIHelper.psm1' } | ForEach-Object { Import-Module $_.FullName -Force -ErrorAction Stop } $baseline = Get-Content $syncHash.BaselineSchemaPath -Raw | ConvertFrom-Json # Point the engine's activity sink at this run's log mailbox so the data-collection # helpers (Get-ScubaTenantGraphData, etc.) can report what they pull to the Activity Log. $syncHash.ScAActivitySink = $connectSync.Log # --- Phase A: Microsoft Graph --- [void]$connectSync.Log.Add(@{ Message = "Connecting to Microsoft Graph ($env) for products '$($products -join ', ')'$(if ($appOnly) { " - noninteractive appid ($($syncHash.AppId))" })..."; Level = 'Info' }) if ($appOnly) { $ctx = Connect-ScubaAnalyzerGraph -M365Environment $env -AppId $syncHash.AppId -CertificateThumbprint $syncHash.CertificateThumbprint -Organization $syncHash.Organization } else { $scopes = @() foreach ($p in $products) { $scopes += Get-ScubaAnalyzerScopes -Product $p -BaselineSchema $baseline -ApiCatalogPath $syncHash.ApiCatalogPath -AnalyzerControlPath $syncHash.AnalyzerControlPath } $scopes = @($scopes | Select-Object -Unique) [void]$connectSync.Log.Add(@{ Message = "Requesting Graph scopes: $($scopes -join ', ')"; Level = 'Info' }) $ctx = Connect-ScubaAnalyzerGraph -Scopes $scopes -M365Environment $env } $connectSync.ConnMode = if ($appOnly) { 'Connected (app-only)' } else { 'Connected' } $connectSync.ConnWho = if ($appOnly) { [string]$syncHash.AppId } elseif ($ctx.Account) { [string]$ctx.Account } else { [string]$ctx.ClientId } $connectSync.Environment = $env $connectSync.Connected = $true [void]$connectSync.Log.Add(@{ Message = "Connected as $($ctx.Account) (tenant $($ctx.TenantId))."; Level = 'Info' }) # --- Phase A2: Exchange Online (only if a selected product needs it) --- $connectSync.Status = "Retrieving tenant configuration..." try { $fetchConns = @(Get-ScubaAnalyzerFetchConnections -Products $products -BaselineSchema $baseline -AnalyzerControlPath $syncHash.AnalyzerControlPath) if (@($fetchConns | Where-Object { $_.connectCmdlet -eq 'Connect-ExchangeOnline' }).Count -gt 0) { $connectSync.Status = if ($appOnly) { "Connecting to Exchange Online (appid & certificate)..." } else { "Connecting to Exchange Online..." } [void]$connectSync.Log.Add(@{ Message = $connectSync.Status; Level = 'Info' }) if ($appOnly) { Connect-ScubaAnalyzerExchange -M365Environment $env -AppId $syncHash.AppId -CertificateThumbprint $syncHash.CertificateThumbprint -Organization $syncHash.Organization | Out-Null } else { Connect-ScubaAnalyzerExchange -M365Environment $env -Organization $syncHash.Organization | Out-Null } [void]$connectSync.Log.Add(@{ Message = "Connected to Exchange Online."; Level = 'Info' }) } } catch { # Non-fatal: affected controls report 'could not collect data' instead of a false pass. [void]$connectSync.Log.Add(@{ Message = "Exchange Online connect failed: $($_.Exception.Message). EXO/SecuritySuite checks will be marked for review."; Level = 'Error' }) } # --- Phase B: read tenant data --- $connectSync.Status = "Retrieving tenant configuration from Microsoft Graph..." $tenantData = @{ conditional_access_policies = @(); OrgDisplayName = $null; Organization = $null; TenantId = $null; DisplayNameLookup = @{}; UserUpnLookup = @{} } foreach ($p in $products) { $d = Get-ScubaTenantGraphData -Product $p -BaselineSchema $baseline -ApiCatalogPath $syncHash.ApiCatalogPath -AnalyzerControlPath $syncHash.AnalyzerControlPath if (@($d.conditional_access_policies).Count -gt 0) { $tenantData.conditional_access_policies = $d.conditional_access_policies } if ($d.OrgDisplayName) { $tenantData.OrgDisplayName = $d.OrgDisplayName } if ($d.Organization) { $tenantData.Organization = $d.Organization } if ($d.TenantId) { $tenantData.TenantId = $d.TenantId } if ($d.DisplayNameLookup) { foreach ($k in @($d.DisplayNameLookup.Keys)) { $tenantData.DisplayNameLookup[$k] = $d.DisplayNameLookup[$k] } } if ($d.UserUpnLookup) { foreach ($k in @($d.UserUpnLookup.Keys)) { $tenantData.UserUpnLookup[$k] = $d.UserUpnLookup[$k] } } foreach ($k in @($d.Keys)) { if ($k -in @('conditional_access_policies','OrgDisplayName','Organization','TenantId','DisplayNameLookup','UserUpnLookup')) { continue } if ($null -ne $d[$k]) { $tenantData[$k] = $d[$k] } } } if (@($tenantData.conditional_access_policies).Count -gt 0 -and $tenantData.UserUpnLookup.Count -eq 0) { try { [void]$connectSync.Log.Add(@{ Message = "Resolving user principal names from $(@($tenantData.conditional_access_policies).Count) Conditional Access policies..."; Level = 'Info' }) $resolvedUserUpns = Get-ScAUserPrincipalNameLookup -Policies @($tenantData.conditional_access_policies) foreach ($key in @($resolvedUserUpns.Keys)) { $tenantData.UserUpnLookup[$key] = $resolvedUserUpns[$key] } [void]$connectSync.Log.Add(@{ Message = "Resolved $($tenantData.UserUpnLookup.Count) user principal name(s) for Tenant Governance."; Level = 'Info' }) } catch { [void]$connectSync.Log.Add(@{ Message = "User principal-name resolution skipped: $($_.Exception.Message)"; Level = 'Warning' }) } } $connectSync.TenantLabel = if ($tenantData.Organization) { $tenantData.Organization } elseif ($tenantData.OrgDisplayName) { $tenantData.OrgDisplayName } elseif ($syncHash.Organization) { $syncHash.Organization } else { $null } [void]$connectSync.Log.Add(@{ Message = "Retrieved $(@($tenantData.conditional_access_policies).Count) Conditional Access policy/policies."; Level = 'Info' }) # --- Phase C: validate against the baselines --- $connectSync.Status = "Analyzing $(@($tenantData.conditional_access_policies).Count) policies against the baselines..." $connectSync.Result = Invoke-ScubaTenantScan -Product $products -M365Environment $env -TenantData $tenantData -BaselineSchemaPath $syncHash.BaselineSchemaPath -AnalyzerControlPath $syncHash.AnalyzerControlPath -ConfigSchemaPath $syncHash.ConfigSchemaPath } catch { $connectSync.Error = $_.Exception.Message Write-ScubaAnalyzerLog "Error occurred during tenant scan: $($_.Exception.Message)" -Level Error } finally { $syncHash.ScAActivitySink = $null $connectSync.IsComplete = $true } }) $syncHash.ConnectBgPS = $bgPS $syncHash.ConnectBgHandle = $bgPS.BeginInvoke() $syncHash.ConnectBgRunspace = $bg # UI timer: reflect status/log/header and, on completion, repaint findings + re-enable. $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(250) $timer.Add_Tick({ $cs = $syncHash.ConnectSync if (-not $cs) { return } # Drain queued log lines (worker only appends; the UI only removes from the front). while ($cs.Log.Count -gt 0) { $item = $null try { $item = $cs.Log[0]; $cs.Log.RemoveAt(0) } catch { break } if ($item) { Write-ScubaAnalyzerLog $item.Message -Level $item.Level } } # Current status line. if ($cs.Status -and $cs.Status -ne $syncHash.LastConnectStatus) { Set-ScubaAnalyzerStatus $cs.Status $syncHash.LastConnectStatus = $cs.Status } # Header: apply the connected badge once, then keep the tenant label current. if ($cs.Connected -and -not $cs.HeaderApplied) { $cs.HeaderApplied = $true $syncHash.ConnectedTenant = $true $syncHash.ConnectedEnvironment = $cs.Environment if ($syncHash.ConnectionText -and $cs.ConnMode) { $syncHash.ConnectionText.Text = "$($cs.ConnMode): $($cs.ConnWho) ($($cs.Environment))" $syncHash.ConnectionText.Foreground = [System.Windows.Media.Brushes]::LightGreen } } if ($cs.TenantLabel -and $syncHash.TenantText -and $syncHash.TenantText.Text -ne "Tenant: $($cs.TenantLabel)") { $syncHash.TenantText.Text = "Tenant: $($cs.TenantLabel)" } if (-not $cs.IsComplete) { return } $syncHash.ConnectTimer.Stop() # Drain + dispose the worker (guarded so one failure can't abort the others). try { [void]$syncHash.ConnectBgPS.EndInvoke($syncHash.ConnectBgHandle) } catch { Write-Verbose "Connect EndInvoke cleanup: $($_.Exception.Message)" } try { $syncHash.ConnectBgPS.Dispose() } catch { Write-Verbose "Connect PS dispose cleanup: $($_.Exception.Message)" } try { $syncHash.ConnectBgRunspace.Close(); $syncHash.ConnectBgRunspace.Dispose() } catch { Write-Verbose "Connect runspace dispose cleanup: $($_.Exception.Message)" } $syncHash.Run_Progress.Visibility = 'Collapsed'; $syncHash.Run_Progress.IsIndeterminate = $false $syncHash.Run_Button.IsEnabled = $true if ($cs.Error) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ScanFailed' $cs.Error) Write-ScubaAnalyzerLog "Scan failed: $($cs.Error)" -Level Error return } # Success: publish, repaint, and jump back to the Findings tab. $syncHash.Analysis = $cs.Result Update-ScubaAnalyzerFindings Update-ScubaAnalyzerFullYaml Update-ScubaAnalyzerTenantGovernanceJson $syncHash.MainTabs.SelectedIndex = 0 $count = @($syncHash.Analysis.Findings | Where-Object { $_.Result -ne 'Pass' }).Count Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ScanComplete' $count) Write-ScubaAnalyzerLog "Scan complete." }) $syncHash.ConnectTimer = $timer $timer.Start() } catch { $syncHash.Run_Button.IsEnabled = $true $syncHash.Run_Progress.Visibility = 'Collapsed'; $syncHash.Run_Progress.IsIndeterminate = $false Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ScanError' $_.Exception.Message) Write-ScubaAnalyzerLog "Scan error: $($_.Exception.Message)" -Level Error } } # ------------------------------------------------------------------------------------ # Open the ScubaConfig app in-process (no new console / window-process) # ------------------------------------------------------------------------------------ function Open-ScubaConfigAppFromAnalyzer { <# .SYNOPSIS Opens Start-SCuBAConfigApp inside this process (its own runspace, no extra console window) and, when a configuration YAML has been generated, pre-loads it so the detected exclusions are already populated. .DESCRIPTION The ScubaConfig app is a second WPF window, and every WPF window needs its own STA thread - so unlike the MTA analysis/scan workers above, this creates an STA runspace. We do NOT poll it with a DispatcherTimer: it is a fire-and-forget UI, not a job that returns a value. We only keep the runspace/PowerShell handles alive (in ConfigAppInstances) so the garbage collector doesn't tear the window down. The generated YAML is written to a temp file and passed via -ConfigFilePath; when the analyzer authenticated (app-only or live), those creds/environment are forwarded so the config app opens Online without a second sign-in. #> try { if (-not $syncHash.ScubaConfigAppModulePath -or -not (Test-Path $syncHash.ScubaConfigAppModulePath)) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ConfigAppNotFound') return } # Pre-load the generated config so the exclusions carry over into the app. # (Written to TEMP; the config app reads it via -ConfigFilePath.) $configFile = $null if ($syncHash.FullYaml_TextBox -and -not [string]::IsNullOrWhiteSpace($syncHash.FullYaml_TextBox.Text)) { $tmpDir = Join-Path $env:TEMP 'ScubaConfigAnalyzer' if (-not (Test-Path $tmpDir)) { New-Item -Path $tmpDir -ItemType Directory -Force | Out-Null } $configFile = Join-Path $tmpDir "AnalyzerConfig_$(Get-Date -Format 'yyyyMMdd_HHmmss').yaml" [System.IO.File]::WriteAllText($configFile, $syncHash.FullYaml_TextBox.Text, [System.Text.Encoding]::UTF8) } # If the analyzer used app-only cert auth, or is connected to a live tenant, open the # config app Online against the same environment so the user isn't prompted to sign in # again. App-only creds (when provided) are forwarded so the config app connects silently. $appOnly = [bool]($syncHash.AppId -and $syncHash.CertificateThumbprint) $online = [bool]$syncHash.ConnectedTenant -or $appOnly $envName = if ($syncHash.ConnectedEnvironment) { [string]$syncHash.ConnectedEnvironment } elseif ($syncHash.Environment_ComboBox.SelectedItem) { [string]$syncHash.Environment_ComboBox.SelectedItem } else { 'commercial' } $rs = [runspacefactory]::CreateRunspace() $rs.ApartmentState = 'STA'; $rs.ThreadOptions = 'ReuseThread'; $rs.Open() # STA: it hosts a WPF window $rs.SessionStateProxy.SetVariable('modulePath', $syncHash.ScubaConfigAppModulePath) $rs.SessionStateProxy.SetVariable('configFile', $configFile) $rs.SessionStateProxy.SetVariable('online', $online) $rs.SessionStateProxy.SetVariable('envName', $envName) $rs.SessionStateProxy.SetVariable('appId', ([string]$syncHash.AppId)) $rs.SessionStateProxy.SetVariable('certThumbprint', ([string]$syncHash.CertificateThumbprint)) $rs.SessionStateProxy.SetVariable('tenantName', ([string]$syncHash.Organization)) $ps = [powershell]::Create(); $ps.Runspace = $rs [void]$ps.AddScript({ Import-Module $modulePath -Force $p = @{} if ($configFile -and (Test-Path $configFile)) { $p.ConfigFilePath = $configFile } if ($online) { $p.Online = $true $p.M365Environment = $envName if ($appId -and $certThumbprint) { # Forward app-only creds so the config app connects without a new sign-in. $p.AppId = $appId $p.CertificateThumbprint = $certThumbprint if ($tenantName) { $p.TenantName = $tenantName } } } Start-SCuBAConfigApp @p }) [void]$ps.BeginInvoke() # Keep references so the runspace isn't collected while the config app is open. if (-not $syncHash.ConfigAppInstances) { $syncHash.ConfigAppInstances = [System.Collections.ArrayList]::new() } [void]$syncHash.ConfigAppInstances.Add(@{ Runspace = $rs; PowerShell = $ps }) $mode = if ($appOnly) { " (Online, app-only: $envName)" } elseif ($online) { " (Online: $envName)" } else { "" } if ($configFile) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ConfigAppOpenedWithConfig' $mode) } else { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ConfigAppOpened' $mode) } } catch { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ConfigAppOpenError' $_.Exception.Message) Write-ScubaAnalyzerLog "Open ScubaConfig app failed: $($_.Exception.Message)" -Level Error } } # ------------------------------------------------------------------------------------ # Export # ------------------------------------------------------------------------------------ function Export-ScubaAnalyzerYaml { <# .SYNOPSIS Prompts for a path and saves the aggregate configuration YAML (FullYaml_TextBox) to disk as UTF-8. No-op with a status message if nothing has been generated yet. #> try { if ([string]::IsNullOrWhiteSpace($syncHash.FullYaml_TextBox.Text)) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'NothingToExport') return } $dlg = New-Object Microsoft.Win32.SaveFileDialog $dlg.Filter = "YAML files (*.yaml)|*.yaml|All files (*.*)|*.*" $dlg.FileName = "ScubaGearConfig.yaml" $dlg.Title = "Export ScubaGear configuration" if ($dlg.ShowDialog() -eq $true) { [System.IO.File]::WriteAllText($dlg.FileName, $syncHash.FullYaml_TextBox.Text, [System.Text.Encoding]::UTF8) Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ConfigExported' $dlg.FileName) Write-ScubaAnalyzerLog "Configuration exported to $($dlg.FileName)" } } catch { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ExportFailed' $_.Exception.Message) } } # ------------------------------------------------------------------------------------ # Initialization + event wiring # ------------------------------------------------------------------------------------ function Initialize-ScubaConfigAnalyzerUI { <# .SYNOPSIS Populates the toolbar and wires all UI events. Called once from the runspace after the XAML has loaded. .DESCRIPTION This is the "table of contents" for the window - read it to find which handler runs for any control. It executes top-to-bottom in these sections: 1. Logo + version text. 2. Header pre-fill from launch params (tenant, and app-only app id in amber). 3. Environment dropdown - filled from ScubaConfigSchema.json enum. 4. Products list - configurable products intersected with baselines. 5. Toolbar buttons - Run (Connect & Scan) and Load (results file). 6. Filters - Issues-only / Configurable-only / search -> refilter. 7. YAML copy/export buttons. 8. "View baseline" button - jumps into the shared policy viewer. 9. "Open ConfigApp" button - hands the generated YAML to Open-ScubaConfigAppFromAnalyzer. 10. "Use this policy" - one routed handler on the policy list catches every card's button (see the AddHandler note below). Every handler is a closure over $syncHash, so it can still reach the controls long after this function has returned. #> # Logo + version try { if ($syncHash.ImgPath -and (Test-Path $syncHash.ImgPath)) { $syncHash.LogoImage.Source = $syncHash.ImgPath } } catch { Write-Verbose "Logo load failed: $($_.Exception.Message)" } if ($syncHash.AnalyzerVersion) { $syncHash.VersionText.Text = "v$($syncHash.AnalyzerVersion)" } # Pre-fill the header from launch parameters: show the tenant (Organization) and, when launched # with app-only creds, the app id (amber = configured but not connected until Connect & Scan). if ($syncHash.Organization -and $syncHash.TenantText) { $syncHash.TenantText.Text = "Tenant: $($syncHash.Organization)" } if ($syncHash.ConnectionText -and $syncHash.AppId -and $syncHash.CertificateThumbprint) { $syncHash.ConnectionText.Text = "(not connected) App-only: $($syncHash.AppId)" $syncHash.ConnectionText.Foreground = [System.Windows.Media.Brushes]::Goldenrod } # Environments: pull from ScubaConfigSchema.json (properties.M365Environment.enum). try { $cs = Get-Content $syncHash.ConfigSchemaPath -Raw | ConvertFrom-Json foreach ($e in @($cs.properties.M365Environment.enum)) { [void]$syncHash.Environment_ComboBox.Items.Add($e) } } catch { Write-ScubaAnalyzerLog "Could not load M365Environment enum from schema: $($_.Exception.Message)" -Level Error } if ($syncHash.Environment_ComboBox.Items.Count -gt 0) { $syncHash.Environment_ComboBox.SelectedIndex = 0 } # Products list: only configurable products (supportsExclusions=true in # ScubaConfigSchema.json), intersected with products that have baseline validations. # Items carry the product key + a friendly displayName (from the analyzer schema # productMap). Multi-select; nothing hardcoded. $configurable = @() try { $cs = Get-Content $syncHash.ConfigSchemaPath -Raw | ConvertFrom-Json foreach ($p in $cs.schemaMetadata.productCapabilities.PSObject.Properties) { if ($p.Value.supportsExclusions -eq $true) { $configurable += ([string]$p.Name).ToLower() } } } catch { Write-ScubaAnalyzerLog "Configurable products load failed: $($_.Exception.Message)" -Level Error } $displayMap = @{} try { $as = Get-Content $syncHash.AnalyzerControlPath -Raw | ConvertFrom-Json foreach ($p in $as.productMap.PSObject.Properties) { if ($p.Name -match '^_') { continue } $displayMap[([string]$p.Name).ToLower()] = if ($p.Value.displayName) { [string]$p.Value.displayName } else { [string]$p.Name } } } catch { Write-ScubaAnalyzerLog "Product display-name load failed: $($_.Exception.Message)" -Level Error } $products = @() try { $bs = Get-Content $syncHash.BaselineSchemaPath -Raw | ConvertFrom-Json $baselineProducts = @($bs.baselineValidations.PSObject.Properties.Name) $products = @($baselineProducts | Where-Object { $configurable -contains ([string]$_).ToLower() }) } catch { Write-ScubaAnalyzerLog "Baseline products load failed: $($_.Exception.Message)" -Level Error } if (@($products).Count -eq 0) { $products = @($configurable) } if (@($products).Count -eq 0) { $products = @('aad') } foreach ($p in $products) { $key = ([string]$p).ToLower() $disp = if ($displayMap.ContainsKey($key)) { $displayMap[$key] } else { [string]$p } [void]$syncHash.Product_ListBox.Items.Add([pscustomobject]@{ Key = [string]$p; Display = $disp }) } $syncHash.Product_ListBox.SelectAll() # Toolbar $syncHash.Run_Button.Add_Click({ Start-ScubaAnalyzerTenantScan }) $syncHash.Load_Button.Add_Click({ try { $dlg = New-Object Microsoft.Win32.OpenFileDialog $dlg.Filter = "ScubaGear results (*.json)|*.json|All files (*.*)|*.*" $dlg.Title = "Select a ScubaResults JSON" if ($dlg.ShowDialog() -eq $true) { Start-ScubaAnalyzerAnalysis -ResultsPath $dlg.FileName } } catch { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'LoadFileError' $_.Exception.Message) } }) # Filters $syncHash.IssuesOnly_CheckBox.Add_Checked({ if ($syncHash.Analysis) { Update-ScubaAnalyzerFindings } }) $syncHash.IssuesOnly_CheckBox.Add_Unchecked({ if ($syncHash.Analysis) { Update-ScubaAnalyzerFindings } }) if ($syncHash.ConfigurableOnly_CheckBox) { $syncHash.ConfigurableOnly_CheckBox.Add_Checked({ if ($syncHash.Analysis) { Update-ScubaAnalyzerFindings } }) $syncHash.ConfigurableOnly_CheckBox.Add_Unchecked({ if ($syncHash.Analysis) { Update-ScubaAnalyzerFindings } }) } $syncHash.Search_TextBox.Add_TextChanged({ if ($syncHash.Analysis) { Update-ScubaAnalyzerFindings } }) $syncHash.Findings_List.Add_SelectionChanged({ Show-ScubaAnalyzerDetail }) $syncHash.Environment_ComboBox.Add_SelectionChanged({ if ($syncHash.Analysis) { Update-ScubaAnalyzerFullYaml } }) # YAML copy/export $syncHash.CopyControlYaml_Button.Add_Click({ try { [System.Windows.Clipboard]::SetText($syncHash.Detail_Yaml.Text); Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ControlYamlCopied') } catch { Write-Verbose "Clipboard copy failed: $($_.Exception.Message)" } }) $syncHash.CopyAllYaml_Button.Add_Click({ try { [System.Windows.Clipboard]::SetText($syncHash.FullYaml_TextBox.Text); Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'ConfigYamlCopied') } catch { Write-Verbose "Clipboard copy failed: $($_.Exception.Message)" } }) $syncHash.ExportYaml_Button.Add_Click({ Export-ScubaAnalyzerYaml }) if ($syncHash.TenantGovernanceTab) { $syncHash.TenantGovernanceTab.Visibility = if ($syncHash.GenerateTenantGovernanceConfig) { 'Visible' } else { 'Collapsed' } $syncHash.CopyTenantGovernance_Button.Add_Click({ Copy-ScubaAnalyzerTenantGovernanceJson }) $syncHash.ExportTenantGovernance_Button.Add_Click({ Export-ScubaAnalyzerTenantGovernanceJson }) if ($syncHash.TenantGovernanceScubaOnly_CheckBox) { # Click fires on every user toggle (IsChecked is already updated) - more reliable than # Checked/Unchecked in this runspace. Rebuild the JSON so the filter applies both ways. $syncHash.TenantGovernanceScubaOnly_CheckBox.Add_Click({ Update-ScubaAnalyzerTenantGovernanceJson }) } } # Jump to the current control in the ScubaGear baseline policy viewer $syncHash.ViewBaseline_Button.Add_Click({ try { $f = $syncHash.Findings_List.SelectedItem if (-not $f) { return } if (-not $syncHash.ShowBaselinePolicyViewer) { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'BaselineViewerUnavailable') return } Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'OpeningBaselineViewer' $f.ControlId) & $syncHash.ShowBaselinePolicyViewer -NavigateToPolicyId $f.ControlId | Out-Null } catch { Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'BaselineViewerError' $_.Exception.Message) Write-ScubaAnalyzerLog "Baseline viewer error: $($_.Exception.Message)" -Level Error } }) # Open the ScubaConfig app (in-process) with the detected exclusions pre-loaded $syncHash.OpenConfigApp_Button.Add_Click({ Open-ScubaConfigAppFromAnalyzer }) # "Use this policy" buttons live inside the policy-card template; catch their clicks # at the container via the bubbling Button.Click routed event. if ($syncHash.Detail_Policies) { $syncHash.Detail_Policies.AddHandler( [System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ param($eventSender, $e) $null = $eventSender # sender unused; the routed event args carry the clicked button try { $btn = ($e.Source -as [System.Windows.Controls.Button]) if (-not $btn) { $btn = ($e.OriginalSource -as [System.Windows.Controls.Button]) } if ($btn -and $btn.Tag) { Select-ScubaAnalyzerPolicy -PolicyId ([string]$btn.Tag) } } catch { Write-ScubaAnalyzerLog "Use-policy click handler failed: $($_.Exception.Message)" -Level Error } } ) } Set-ScubaAnalyzerStatus (Get-ScubaAnalyzerText 'Ready') } # No Export-ModuleMember: like the other analyzer helper modules, all functions are exported # so the launcher runspace can call them (Set-ScubaAnalyzerStatus, Write-ScubaAnalyzerLog, # Export-ScubaAnalyzerYaml, Select-ScubaAnalyzerPolicy, etc.). # SIG # Begin signature block # MIIu9wYJKoZIhvcNAQcCoIIu6DCCLuQCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCADuachfqS/2KOh # PAaJjcUmNGRObGlngo7q4MYwNeCBw6CCE6MwggWQMIIDeKADAgECAhAFmxtXno4h # MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z # ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB # AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z # G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ # anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s # Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL # 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb # BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 # JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c # AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx # YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 # viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL # T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud # EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf # Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk # aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS # PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK # 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB # cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp # 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg # dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri # RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 # 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 # nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 # i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H # EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G # CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C # 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce # 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da # E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T # SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA # FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh # D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM # 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z # 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 # huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY # mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP # /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN # BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry # sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL # IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf # Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh # OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh # dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV # 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j # wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH # Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC # XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l # /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW # eE4wggdXMIIFP6ADAgECAhAMM6tnPejLgA9WVhXroQvSMA0GCSqGSIb3DQEBCwUA # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwHhcNMjYwMTE0MDAwMDAwWhcNMjcwMTEzMjM1OTU5WjBfMQsw # CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV # BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCuXYolNHqlh6smLTE592waXheZ # 8VHzxeds4pMaepGuwmjf8d1jG9wUNuJX9/qb0a1dgGz5D/EAz5NRTIin4SZYQEE8 # qvdl2yQJ5uWxXIjsFbrOyc1fWscUXw0Kt7OPLOafcEkdDoe8K0tO4h2GL3RWRzjp # uLfQhhnAmD6NT1l+ughnfmarV/ODgIn/RFR4YORlu4YP2xQX6KRxeTDslg7F+z6X # +t87/U8m8gQ9XTm5kBmteP4GcE/ytnyI+ScIxNRybzGomWIBm848XDE5yYhlYQ2R # SnCoo6M4CRqp9WFGVyoLkoPP0OlxzryKWaE1/nuPbYG/kf/rUB1OhqxvSSGwmNhs # vkkjsC0Z9H5Jy6heFdoxOu/+ZQksKoP/fMvHxuCCtkIJbV8tk0oT6MQ8EJbgsWDZ # TKhui1wxW6JIZyBOMPWoZUOouOzo2h5Cz7LBPKME5FkcUzcs47lpRlDkJco4PLcj # wJSo4XPnx3G/2DIjNEFNyfKCWfH8uW6nJjmDBiveFZ2j0YvgdQ+7MOjQnw7R/MAD # DTagrKl3rLV60+X2TY6/onKhCUuU3pMAjVbOwZ3PkzDLZnsEGRfm6hgp6014aXml # t8h4nu+uC41U8vUSGHl0vqKuzvmShLmnI+Iv0l95pmnqomuCZzRDrjEoaLPx7OxL # Dy/Id9E7yQDip4jBdQIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R # hvv+YXsIiGX0TkIwHQYDVR0OBBYEFN30sVU+fpfQQfPQWMhkO1qd+MxoMD4GA1Ud # IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl # cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw # gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j # cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0 # ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF # BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t # MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl # cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ # BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQA77T42YiOx5wWPItgo+kvB+Gzb # ZFCRHRFfTAZZNQt9o/0CqNmyA2xFklX4t5Z9VNdiIOx14AnmJdQkcRdk3vsU5gby # jKEup7LTWtvcWrl6hQwGNt3l892BgUbPKsPBE+AriktVqn5yMSXVVzeboqsqAG8e # Syei0B54/QdgR4whfHvQ/qpCACsJTlJgAykXVgDPJNKnQ7wc17loLQutqF1JUcbO # XKWt1AA9Zas5q30LDZzZeK4B56yojK68CTQXN7toSRFIuZDMKKDfIZpCX8cmbaaO # DFOOu44/QWMv+Xc6+ISYGkrTTzqWhOqiXgLVBeXGn/WrOJJ8R29mZMneCpBesCLs # YII1gCFOo7Vt6mvOKxAPQ3KhJYBFEHkp+GI65koaQkO2xv50iLS0+/j2YC66uviU # MFe0JEOdXuE7Rn/OmWNSzQ+6kPNYDJQASQ974C3wUejJoMtGZEzoTbly/HufQTrd # rhcL2aC69CxSN+idTXPLC9UT3xo4sFdOw+hXkmbXtoB1GDsd5p1TWFgRbnTXDkbM # YMBWYVB6/Tk1bzwj4iTp4g0YrtB628FXPX/ko+JWZlv0Ea865S23w1uGlnDNVxIi # +8oi74G5DM66Q6ENt6+3WoRGRrdoyE6uCh1haY+oPYSgumb0ozzrp8tw89TRKVrK # KSXGBxlExEvjQ6dYwjGCGqowghqmAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV # BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0 # IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDDOrZz3oy4AP # VlYV66EL0jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh # AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM # BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCEZpyL5TheAKkwaxhKLOfQcNvT # 2V8Wfe4oVwrk2wXIqDANBgkqhkiG9w0BAQEFAASCAgB0CDUbMnjKkkVVvYcYCPoV # VHYT6wrppfd1bc88iqX7WsnwyppTnAHkQQoOKpA1JISOtbFxpdTwOxCgETARNsvk # clkhb3FY9IIGZal9PvXpZPQi9K/CaFKq1o41+5WAO5griBD8EkLdarsfjNhvVcEm # zN4bB+SeC+7pl7reWd/+6wcBTsvhW1YP8nRvvfp2QpDW3nkwZTgSm3kkv6+lueHy # HfwbNi6MWir5lQ3h+6KKe6QGgkciWRxFlwYoeKS8qv5UGhAwCucaU9DHcxE5UbIX # QR22QTijTUc9phVetlZgmxH694mEjc4rgoyRM8FT09RWo06LnIpuLfmgsOCoxDDV # NWabsXLTOxW+F/tOqYiIsknfIWOOi/RQEPBVLQc2WePrTBew7SYcE3MK7hpt6JhG # W5hcmZ5YNDbleDesAlh8LzjkZ4pfNnAOi3oRoymSu2qJvDfDYuT+sYlnCv5CulEx # d5wViknz8qe6RK8Nv1od7H8jKsEGaogmEfyLn0UKmevcYAKtFmS8piBROmte+l9E # SnbGabQcJL08Kb3XjQM9yrYnKld9E42Mv4DxuZ0Fd2EHD0yjH26j+dUbRa18wUJa # BIuJZSxttwVtYUsFH9aQKn8GxZ4OCxUaHjv/SSPy96rwGOf/x0P54wGxCuAsMzeA # S22a7FoO1igb6qvkYIv9CqGCF3cwghdzBgorBgEEAYI3AwMBMYIXYzCCF18GCSqG # SIb3DQEHAqCCF1AwghdMAgEDMQ8wDQYJYIZIAWUDBAIBBQAweAYLKoZIhvcNAQkQ # AQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCDsP8A6q47W # 5QHmHek1miePhjqWUlD2JtOyHLgxbv0SmgIRAPPh3sr5UUJPnoFOmhYxqk0YDzIw # MjYxMDIyMjEzMDQ0WqCCEzowggbtMIIE1aADAgECAhAIT9wzT35FTtvDD4/5khg1 # MA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy # dCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBp # bmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwHhcNMjYwODA1MDAwMDAwWhcNMzcx # MTA0MjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu # Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFNIQTI1NiBSU0E0MDk2IFRpbWVzdGFtcCBS # ZXNwb25kZXIgMjAyNiAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA # tnum8sn+zUr41JtMZbP9OMYw+HwJDpG5xkIu/lqcfNYmMX81YmsUiHLbh9ykpeWB # GKTLhYBrAN9Tdg/QEzG32XcObmgIblnr0CoQ3WSAeDZ6nH6X6VkFyYkJw3QBJREw # vm4UhLzSxmwPA7cFKRTEOMsmEEj6qJk/dqLEAL+oQYuOwE2UuiX1Vnul8YReIyWd # 4kgLn9gq6LNXM0UplkR6jL/QHxmb6fMoGBJYbnaUI7XD6cKDpekK2SVMld4iDbze # HDtOaaxldH5IxuNusQ69nd8/ZXEiB5Hbxj3RlK13cX1W4DlFXKdv/CEhM8Cj1vvl # mvhNroyPdRGbbpBlgyf8Wdu5N6ByhFwURn0U6ozlPoxN22v+fviUhP+6DR547OZn # pBMWDfei1f5sVGwiiW/KQTWOK97g+4RJpPzPNV4VYMAwO2jM2Aty2QYPVmOQTJm0 # msuXnJrSbl2gf9JylpkJlWXqk1Q4LJsxz+TELoQCZIljbgvTJgoPU2R12ydv8i1U # qL/adelA0y7U9Pmmtbze9Xx3rtajC5SzQd1jgfwAwsa90v9YcSPdmeoyoBBA/27c # CL237l5DTYYPDLQ4ON3OLTGWnvRb6jDrf/T75gMRfUzSLCBQfBusm9+mSWRlC/Df # 6S/e9Q8i13CuhzOT2Jx+V/nlbXM4QoBwlUAhelwwJT0CAwEAAaOCAZUwggGRMAwG # A1UdEwEB/wQCMAAwHQYDVR0OBBYEFBTJY4owLtRK+26U8+bjQH717M3iMB8GA1Ud # IwQYMBaAFO9vU0rp5AZ8esrikFb2L9RJ7MtOMA4GA1UdDwEB/wQEAwIHgDAWBgNV # HSUBAf8EDDAKBggrBgEFBQcDCDCBlQYIKwYBBQUHAQEEgYgwgYUwJAYIKwYBBQUH # MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBdBggrBgEFBQcwAoZRaHR0cDov # L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1w # aW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3J0MF8GA1UdHwRYMFYwVKBSoFCGTmh0 # dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFt # cGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNybDAgBgNVHSAEGTAXMAgGBmeBDAEE # AjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAI3FOmEenVIK35msCYB+ # fShAsWvSYvLBItoNdAgQ2jIqrGsVsluXMJU/+mRebBc52s6lbKAvOVPXaizmKkML # LflEEKDZQx4CkS2t8aHPjkXha3hYZ010htFa3dhNgmalH5vuWvh3tTCf4frTS7gP # tGc4Z/xaPhQ2AB1mR8eEe/WbH0RWHvVIl6VwQ3+g5FKNfN2N/DWJkf13w2H+2Gfq # Efbd35Ww8CvoYBjLNIDTadcPWdgsjsiOaK/7EsKJgLjUNIVgvcaFOLLQ/GlrA+0Z # HJoFUbOr5SJN8zykPspXIXlpDJY/gqFUZRROeab9GVgmhbdOJcD/63RhxPahFUGb # ckRONqMe6DYAv6/mOG0pWd3cPStsdcS7buj5DyniwRY8yooMH6ptx5vpP/pZzBPB # eZD2U4IsthyxB5Jaa8qrOkB5z160TXiM5ADMspZ0TfD9MJoq0tFpFPssKRFhWeED # YPvcUuN7U7lvcdHl4ezQ3NT/7Ffs1sR1yh/LRbdZ3B3Vc6q2WmD8mDC0p9kzl2o7 # 3iVtS946IkEj7FkRsZGww1teYxERROC745xrtjvcw9ZyyUjHZWGRIpJeMNsPquCD # f0fkyHtB+J4AiNZqCQk23rxh+KbpyMTNVKItJ5l92Svl20U9NbqMBOVYl1h54NEY # LJq1/xHWFKPNK903zJZA9P2DMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJ # hjANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNl # cnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdp # Q2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1 # OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA # tHgx0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkb # sDpz4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+ # AbdJgMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAkt # VJMQbzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3 # Gmq6bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv8 # 2wnJRfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638Z # xqU14lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7V # vwDDjAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2N # L+cnT6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTK # IsDq1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUoz # ZtqgPrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIG # A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtO # MB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIB # hjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUH # MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDov # L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQw # QwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lD # ZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZI # AYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N # 0IfFiBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7J # d2E4/iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1j # EMK/DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdog # P8HRtrYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMv # NYY2o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBld # NE2K9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjT # Y6hc3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/B # dHLiRu7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyI # PbAvjSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8 # dC3JE3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2j # NrbM1pD2T7m3XDCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI # hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ # MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz # dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow # YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290 # IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww # IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5 # 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH # hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6 # Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ # ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b # A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9 # WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU # tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo # ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J # vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP # orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB # Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr # oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt # MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF # BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl # ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw # BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH # vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8 # UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn # f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU # jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j # LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGCA3ww # ggN4AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu # MUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0 # MDk2IFNIQTI1NiAyMDI1IENBMQIQCE/cM09+RU7bww+P+ZIYNTANBglghkgBZQME # AgEFAKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkF # MQ8XDTI2MTAyMjIxMzA0NFowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUUdmr2gNJ # c9hPQmaspIJI5rNpxDkwLwYJKoZIhvcNAQkEMSIEICFqeX4jwVTrf7h09d3F7q3z # vqok7PQH9DpjrXmjcbBtMDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEIC2gnaf0Ex+f # 5y22xebpyWVnVa8EPx6nQswNISDhQev8MA0GCSqGSIb3DQEBAQUABIICAHOB6oA/ # BBBKq0x5L+NDM3EEAQGWLXokKpGP5VMxTokUDrUog/vHZ87ZyIklhmSd5QrZsuNR # zuWaiv/L8yY9NgKd9xlAKo3kxWIK4LMC8lHXeRr70Px5Vc3dDG7N17ldnTD/pASY # gYsKGm0whw7YY3c7qO5YShQQ7jtJqqpvedIZEDip7WW573kXBDU4HOER57GvTsz9 # RwtSUSzpV6pbFynQpsFci2+hH+DkALAMbPaReoiH/NoqP3ywuDz0XscgksuTb8yw # Pg+gb7w2f+t5bLzaiD7WhviJVa9n4kUf6Lhk9X4RC/9ULspfQMzOUo62+tmG7Orw # TBzZlj1h46UwJORZo0RS8hfD4SPRJtEqkMYyqQ4w2IutGi6mJSGvjn1WBeUKyNI6 # IXC06QtLDJRMZO74kP6hcPkhDxz/ZPyDjsufdJzSIlm6Gmhm9wFjLPcjKN/omHW1 # 86sUCYIoreiSXotE96Z4JJTmx2j2DK3M/PpVMTwO6Oh+cVhybKNJ/nlf629HSQa/ # 3elldOX9yH2i5HKDq6AnM7ejbrBzdCZI/585b9SUE6+bxZOYr/8GMgV5B4kiHint # wRJZIRfawedB1CRDdbWEVFJqiKqjaOWf/ei4ZpDtsrLJ5SejgBy3I/Pi2BJPHzNg # nOaarvJf95zf8g0ncrKtvq9fg+BjRv81I/4b # SIG # End signature block |