SecurityAuditReport.ps1

#requires -Version 5.1

function Script:Get-KeeperJsonPropertyValue {
    param(
        [Parameter()]
        $Object,

        [Parameter(Mandatory = $true)]
        [string] $Name,

        [Parameter()]
        $DefaultValue = $null
    )

    if ($null -eq $Object) {
        return $DefaultValue
    }

    $property = $Object.PSObject.Properties[$Name]
    if ($null -ne $property) {
        return $property.Value
    }

    return $DefaultValue
}

function Script:ConvertTo-KeeperInt {
    param(
        [Parameter()]
        $Value,

        [Parameter()]
        [int] $DefaultValue = 0
    )

    if ($null -eq $Value) {
        return $DefaultValue
    }

    $parsed = 0
    if ([int]::TryParse($Value.ToString(), [ref]$parsed)) {
        return $parsed
    }

    return $DefaultValue
}

function Script:Show-KeeperSecurityAuditSyntaxHelp {
    Write-Host @"
Security Audit Report Command Syntax Description:

Column Name Description
  email e-mail address
  name user display name
  sync_pending whether security data sync is pending
  weak number of records whose password strength is in the weak category
  fair number of records whose password strength is in the fair category
  medium number of records whose password strength is in the medium category
  strong number of records whose password strength is in the strong category
  reused number of reused passwords
  unique number of unique passwords
  securityScore security score (0-100)
  twoFactorChannel 2FA - On/Off
  node enterprise node path

BreachWatch Columns (with -BreachWatch):
  at_risk number of at-risk records
  passed number of passed records
  ignored number of ignored records

Switches:
  -Format <table|json|csv> format of the report
  -Output <FILENAME> output to the given filename
  -SyntaxHelp display description of each column in the report
  -Node <name|UID> name(s) or UID(s) of node(s) to filter results by
  -BreachWatch display a BreachWatch security report
  -ShowUpdated calculate current security audit scores locally
  -Save push updated scores to Keeper
  -ScoreType <type> strong_passwords or default
  -AttemptFix reset invalid security-data for affected users
  -Force skip confirmation prompts
"@

}

function Script:Expand-KeeperSecurityAuditData {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Json,

        [Parameter(Mandatory = $true)]
        [int] $NumberOfReusedPasswords
    )

    $parsed = $Json | ConvertFrom-Json
    $secStats = Get-KeeperJsonPropertyValue $parsed 'securityAuditStats'
    $bwStats = Get-KeeperJsonPropertyValue $parsed 'bwStats'
    $hasSecStats = $null -ne $secStats

    function getIntValue {
        param(
            $TopLevel,
            $SecLevel,
            $BwLevel = $null
        )

        $topValue = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $parsed $TopLevel)
        if ($topValue -ne 0) {
            return $topValue
        }

        if ($null -ne $secStats) {
            $secValue = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $secStats $SecLevel)
            if ($secValue -ne 0) {
                return $secValue
            }
        }

        if ($null -ne $bwStats -and -not [string]::IsNullOrEmpty($BwLevel)) {
            return (ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $bwStats $BwLevel))
        }

        return 0
    }

    $weak = getIntValue 'weak_record_passwords' 'weak_record_passwords'
    $fair = getIntValue 'fair_record_passwords' 'fair_record_passwords'
    $medium = getIntValue 'medium_record_passwords' 'medium_record_passwords'
    $strong = getIntValue 'strong_record_passwords' 'strong_record_passwords'
    $total = getIntValue 'total_record_passwords' 'total_record_passwords'
    $unique = [Math]::Max(0, $total - $NumberOfReusedPasswords)
    $passed = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $bwStats 'passed_records')
    $atRisk = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $bwStats 'at_risk_records')
    $ignored = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $bwStats 'ignored_records')

    if (-not $hasSecStats) {
        $medium = $total - $weak - $strong
    }

    return @{
        weak_record_passwords = $weak
        fair_record_passwords = $fair
        medium_record_passwords = $medium
        strong_record_passwords = $strong
        total_record_passwords = $total
        unique_record_passwords = $unique
        passed_records = $passed
        at_risk_records = $atRisk
        ignored_records = $ignored
    }
}

function Script:Get-KeeperSecurityStrengthCategory {
    # Maps Keeper password-strength scores to bucket names.
    # Score mapping: 0-1 = weak, 2 = fair, 3 = medium, 4+ = strong
    param([int] $Score)

    if ($Score -ge 4) { return 'strong' }
    if ($Score -eq 2) { return 'fair' }
    if ($Score -le 1) { return 'weak' }
    return 'medium'
}

function Script:Get-KeeperSecurityScoreDeltas {
    param(
        [Parameter()]
        $RecordSecurityData,

        [Parameter(Mandatory = $true)]
        [int] $Delta
    )

    $deltas = @{
        weak_record_passwords = 0
        fair_record_passwords = 0
        medium_record_passwords = 0
        strong_record_passwords = 0
        total_record_passwords = 0
        unique_record_passwords = 0
        passed_records = 0
        at_risk_records = 0
        ignored_records = 0
    }

    $passwordStrength = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $RecordSecurityData 'strength')
    $strengthKey = '{0}_record_passwords' -f (Get-KeeperSecurityStrengthCategory $passwordStrength)
    if ($deltas.ContainsKey($strengthKey)) {
        $deltas[$strengthKey] = $Delta
    }
    $deltas.total_record_passwords = $Delta

    # bw_result from the API: 2 = at-risk (breached), 1 = passed (clean), 0/other = ignored
    $breachWatchResult = ConvertTo-KeeperInt (Get-KeeperJsonPropertyValue $RecordSecurityData 'bw_result')
    if ($breachWatchResult -eq 2) {
        $deltas.at_risk_records = $Delta
    }
    elseif ($breachWatchResult -eq 1) {
        $deltas.passed_records = $Delta
    }
    else {
        $deltas.ignored_records = $Delta
    }

    return $deltas
}

function Script:Update-KeeperSecurityScoreDeltas {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable] $Data,

        [Parameter(Mandatory = $true)]
        [hashtable] $Deltas
    )

    foreach ($entry in $Deltas.GetEnumerator()) {
        if ($Data.ContainsKey($entry.Key)) {
            $Data[$entry.Key] += $entry.Value
        }
        else {
            $Data[$entry.Key] = $entry.Value
        }
    }
}

function Script:Decrypt-KeeperSecurityData {
    param(
        [Parameter()]
        $SecurityData,

        [Parameter(Mandatory = $true)]
        [Enterprise.EncryptedKeyType] $KeyType,

        [Parameter()]
        $RsaKey,

        [Parameter()]
        $EcKey
    )

    if ($null -eq $SecurityData -or $SecurityData.IsEmpty) {
        return $null
    }

    $dataBytes = $SecurityData.ToByteArray()

    try {
        if ($KeyType -eq [Enterprise.EncryptedKeyType]::KtEncryptedByPublicKeyEcc) {
            if ($null -eq $EcKey) {
                return $null
            }
            $decryptedBytes = [KeeperSecurity.Utils.CryptoUtils]::DecryptEc($dataBytes, $EcKey)
        }
        else {
            if ($null -eq $RsaKey) {
                return $null
            }
            $decryptedBytes = [KeeperSecurity.Utils.CryptoUtils]::DecryptRsa($dataBytes, $RsaKey)
        }
    }
    catch {
        return $null
    }

    try {
        $json = [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
        return ($json | ConvertFrom-Json)
    }
    catch {
        return $null
    }
}

function Script:Update-KeeperSecurityIncrementalData {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable] $Data,

        [Parameter(Mandatory = $true)]
        [Authentication.SecurityReport] $SecurityReport,

        [Parameter()]
        $RsaKey,

        [Parameter()]
        $EcKey,

        [Parameter(Mandatory = $true)]
        [ref] $HasErrors
    )

    $updatedData = @{}
    foreach ($key in $Data.Keys) {
        $updatedData[$key] = $Data[$key]
    }

    foreach ($incrementalData in $SecurityReport.SecurityReportIncrementalData) {
        $oldData = Decrypt-KeeperSecurityData $incrementalData.OldSecurityData $incrementalData.OldDataEncryptionType $RsaKey $EcKey
        $currentData = Decrypt-KeeperSecurityData $incrementalData.CurrentSecurityData $incrementalData.CurrentDataEncryptionType $RsaKey $EcKey

        if (($null -ne $oldData -and $null -eq (Get-KeeperJsonPropertyValue $oldData 'strength')) -or
            ($null -ne $currentData -and $null -eq (Get-KeeperJsonPropertyValue $currentData 'strength'))) {
            $HasErrors.Value = $true
            break
        }

        if ($null -ne $oldData) {
            $deltas = Get-KeeperSecurityScoreDeltas $oldData -1
            Update-KeeperSecurityScoreDeltas $updatedData $deltas
        }
        if ($null -ne $currentData) {
            $deltas = Get-KeeperSecurityScoreDeltas $currentData 1
            Update-KeeperSecurityScoreDeltas $updatedData $deltas
        }
    }

    if ($HasErrors.Value) {
        return $Data
    }

    return $updatedData
}

function Script:Format-KeeperSecurityReportData {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable] $Data
    )

    $report = [ordered]@{
        securityAuditStats = [ordered]@{
            weak_record_passwords = ConvertTo-KeeperInt $Data.weak_record_passwords
            fair_record_passwords = ConvertTo-KeeperInt $Data.fair_record_passwords
            medium_record_passwords = ConvertTo-KeeperInt $Data.medium_record_passwords
            strong_record_passwords = ConvertTo-KeeperInt $Data.strong_record_passwords
            total_record_passwords = ConvertTo-KeeperInt $Data.total_record_passwords
            unique_record_passwords = ConvertTo-KeeperInt $Data.unique_record_passwords
        }
        bwStats = [ordered]@{
            passed_records = ConvertTo-KeeperInt $Data.passed_records
            at_risk_records = ConvertTo-KeeperInt $Data.at_risk_records
            ignored_records = ConvertTo-KeeperInt $Data.ignored_records
        }
    }

    return ($report | ConvertTo-Json -Depth 5 -Compress)
}

function Script:Get-KeeperStrongByTotal {
    param(
        [Parameter(Mandatory = $true)]
        [int] $Total,

        [Parameter(Mandatory = $true)]
        [int] $Strong
    )

    if ($Total -eq 0) {
        return 0.0
    }

    return ([double]$Strong / [double]$Total)
}

function Script:Get-KeeperSecurityScore {
    param(
        [Parameter(Mandatory = $true)]
        [int] $Total,

        [Parameter(Mandatory = $true)]
        [int] $Strong,

        [Parameter(Mandatory = $true)]
        [int] $Unique,

        [Parameter(Mandatory = $true)]
        [bool] $TwoFactorOn
    )

    $strongByTotal = Get-KeeperStrongByTotal $Total $Strong
    $uniqueByTotal = if ($Total -eq 0) { 0.0 } else { [double]$Unique / [double]$Total }
    $twoFactorValue = if ($TwoFactorOn) { 1.0 } else { 0.0 }
    return ($strongByTotal + $uniqueByTotal + 1.0 + $twoFactorValue) / 4.0
}

function Script:Confirm-KeeperSecurityAuditAction {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Prompt,

        [Parameter()]
        [switch] $Force
    )

    if ($Force) {
        return $true
    }

    $answer = Read-Host $Prompt
    return ($answer -match '^(?i)y(es)?$')
}

function Script:Clear-KeeperSecurityDataForUsers {
    param(
        [Parameter(Mandatory = $true)]
        [Enterprise] $Enterprise,

        [Parameter(Mandatory = $true)]
        [System.Collections.Generic.List[long]] $UserIds
    )

    $chunkSize = 999
    for ($offset = 0; $offset -lt $UserIds.Count; $offset += $chunkSize) {
        $request = New-Object Enterprise.ClearSecurityDataRequest
        $request.Type = [Enterprise.ClearSecurityDataType]::ForceClientResendSecurityData

        $endIndex = [Math]::Min($offset + $chunkSize - 1, $UserIds.Count - 1)
        for ($index = $offset; $index -le $endIndex; $index++) {
            $request.EnterpriseUserId.Add($UserIds[$index]) | Out-Null
        }

        $Enterprise.loader.Auth.ExecuteAuthRest("enterprise/clear_security_data", $request).GetAwaiter().GetResult() | Out-Null
    }

    Write-Host "Security data cleared for $($UserIds.Count) user(s)."
}

function Script:Test-KeeperRunReportsPrivilege {
    param(
        [Parameter(Mandatory = $true)]
        [Enterprise] $Enterprise
    )

    if ($Script:Context.ManagedCompanyId -gt 0) {
        return $true
    }

    $enterpriseData = $Enterprise.enterpriseData
    $roleData = $Enterprise.roleData
    if ($null -eq $enterpriseData -or $null -eq $roleData) {
        return $false
    }

    $currentUser = $null
    if (-not $enterpriseData.TryGetUserByEmail($Enterprise.loader.Auth.Username, [ref]$currentUser)) {
        return $false
    }

    $userRoleIds = [System.Collections.Generic.HashSet[long]]::new()
    foreach ($roleId in $roleData.GetRolesForUser($currentUser.Id)) {
        [void]$userRoleIds.Add($roleId)
    }

    foreach ($managedNode in @($roleData.GetManagedNodes())) {
        if (-not $userRoleIds.Contains($managedNode.RoleId)) {
            continue
        }

        foreach ($privilege in @($roleData.GetPrivilegesForRoleAndNode($managedNode.RoleId, $managedNode.ManagedNodeId))) {
            if ([string]::Equals($privilege.PrivilegeType, 'RUN_REPORTS', [System.StringComparison]::OrdinalIgnoreCase)) {
                return $true
            }
        }
    }

    return $false
}

function Script:Test-KeeperEnterpriseAddonEnabled {
    param(
        [Parameter(Mandatory = $true)]
        [Enterprise] $Enterprise,

        [Parameter(Mandatory = $true)]
        [string] $AddonName
    )

    $license = $Enterprise.enterpriseData.EnterpriseLicense
    if ($null -eq $license) {
        return $false
    }

    if ([string]::Equals($license.LicenseStatus, 'business_trial', [System.StringComparison]::OrdinalIgnoreCase)) {
        return $true
    }

    foreach ($addon in @($license.AddOns)) {
        if (-not [string]::Equals($addon.Name, $AddonName, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        if ($addon.Enabled -or $addon.IncludedInProduct) {
            return $true
        }
    }

    return $false
}

function Script:Write-KeeperSecurityAuditOutput {
    param(
        [Parameter(Mandatory = $true)]
        [object[]] $Rows,

        [Parameter(Mandatory = $true)]
        [bool] $ShowBreachWatch,

        [Parameter(Mandatory = $true)]
        [ValidateSet('table', 'json', 'csv')]
        [string] $Format,

        [Parameter()]
        [string] $Output
    )

    $title = if ($ShowBreachWatch) { 'Security Audit Report (BreachWatch)' } else { 'Security Audit Report' }
    $internalFields = if ($ShowBreachWatch) {
        @('email', 'name', 'sync_pending', 'at_risk', 'passed', 'ignored')
    } else {
        @('email', 'name', 'sync_pending', 'weak', 'fair', 'medium', 'strong', 'reused', 'unique', 'securityScore', 'twoFactorChannel', 'node')
    }

    $displayRows = if ($ShowBreachWatch) {
        $Rows | Select-Object @{Name='Email';Expression={$_.email}},
            @{Name='Name';Expression={$_.name}},
            @{Name='Sync Pending';Expression={$_.sync_pending}},
            @{Name='At Risk';Expression={$_.at_risk}},
            @{Name='Passed';Expression={$_.passed}},
            @{Name='Ignored';Expression={$_.ignored}}
    } else {
        $Rows | Select-Object @{Name='Email';Expression={$_.email}},
            @{Name='Name';Expression={$_.name}},
            @{Name='Sync Pending';Expression={$_.sync_pending}},
            @{Name='Weak';Expression={$_.weak}},
            @{Name='Fair';Expression={$_.fair}},
            @{Name='Medium';Expression={$_.medium}},
            @{Name='Strong';Expression={$_.strong}},
            @{Name='Reused';Expression={$_.reused}},
            @{Name='Unique';Expression={$_.unique}},
            @{Name='Security Score';Expression={$_.securityScore}},
            @{Name='2FA';Expression={$_.twoFactorChannel}},
            @{Name='Node';Expression={$_.node}}
    }

    switch ($Format) {
        'json' {
            $jsonRows = foreach ($row in $Rows) {
                $jsonRow = [ordered]@{}
                foreach ($field in $internalFields) {
                    $jsonRow[$field] = $row.$field
                }
                [PSCustomObject]$jsonRow
            }
            $jsonText = $jsonRows | ConvertTo-Json -Depth 5
            if ($Output) {
                Set-Content -Path $Output -Value $jsonText -Encoding utf8
                Write-Host "Output written to $Output"
                return
            }
            return $jsonText
        }
        'csv' {
            $csvText = ($displayRows | ConvertTo-Csv -NoTypeInformation)
            if ($Output) {
                Set-Content -Path $Output -Value $csvText -Encoding utf8
                Write-Host "Output written to $Output"
                return
            }
            return $csvText
        }
        default {
            if ($Output) {
                $tableText = @($displayRows | Format-Table -Property * -AutoSize | Out-String -Width 8192)
                Set-Content -Path $Output -Value @($title, '', $tableText) -Encoding utf8
                Write-Host "Output written to $Output"
                return
            }

            Write-Host ""
            Write-Host $title
            $displayRows | Format-Table -Property * -AutoSize | Out-String -Width 8192
        }
    }
}

function Get-KeeperSecurityAuditReport {
    <#
    .SYNOPSIS
    Run a security audit report.

    .DESCRIPTION
    Retrieves enterprise security audit data from Keeper, decrypts each user's report
    payload, optionally applies incremental security data updates, and returns the
    results in table, JSON, or CSV form.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('table', 'json', 'csv')]
        [string] $Format = 'table',

        [Parameter()]
        [string] $Output,

        [Parameter()]
        [switch] $SyntaxHelp,

        [Parameter()]
        [string[]] $Node,

        [Parameter()]
        [switch] $BreachWatch,

        [Parameter()]
        [switch] $ShowUpdated,

        [Parameter()]
        [switch] $Save,

        [Parameter()]
        [ValidateSet('strong_passwords', 'default')]
        [string] $ScoreType = 'default',

        [Parameter()]
        [switch] $AttemptFix,

        [Parameter()]
        [switch] $Force
    )

    if ($SyntaxHelp) {
        Show-KeeperSecurityAuditSyntaxHelp
        return
    }

    [Enterprise]$enterprise = getEnterprise
    if ($null -eq $enterprise.enterpriseData) {
        Write-Warning "Enterprise data is not available."
        return
    }

    $treeKey = $enterprise.loader.TreeKey
    if ($null -eq $treeKey -or $treeKey.Length -eq 0) {
        Write-Warning "Enterprise tree key is not available. Ensure enterprise data is loaded."
        return
    }

    $nodeFilter = Resolve-EnterpriseNodeFilter -Enterprise $enterprise -Nodes $Node
    $showUpdatedData = $ShowUpdated.IsPresent -or $Save.IsPresent
    $saveReport = $Save.IsPresent
    $useStrongPasswordScoring = [string]::Equals($ScoreType, 'strong_passwords', [System.StringComparison]::OrdinalIgnoreCase)

    $rsaKey = $null
    if ($enterprise.loader.RsaPrivateKey -and $enterprise.loader.RsaPrivateKey.Length -gt 0) {
        try {
            $rsaKey = [KeeperSecurity.Utils.CryptoUtils]::LoadRsaPrivateKey($enterprise.loader.RsaPrivateKey)
        }
        catch { Write-Verbose "Failed to load RSA private key from enterprise loader: $_" }
    }

    $ecKey = $null
    if ($enterprise.loader.EcPrivateKey -and $enterprise.loader.EcPrivateKey.Length -gt 0) {
        try {
            $ecKey = [KeeperSecurity.Utils.CryptoUtils]::LoadEcPrivateKey($enterprise.loader.EcPrivateKey)
        }
        catch { Write-Verbose "Failed to load EC private key from enterprise loader: $_" }
    }

    $rows = New-Object System.Collections.Generic.List[object]
    $invalidUsers = New-Object 'System.Collections.Generic.List[long]'
    $updatedSecurityReports = New-Object 'System.Collections.Generic.List[Authentication.SecurityReport]'
    $saveBuildFailures = 0
    $fromPage = 0L
    $complete = $false
    $asOfRevision = 0L
    $hasErrors = $false

    while (-not $complete) {
        $request = New-Object Authentication.SecurityReportRequest
        $request.FromPage = $fromPage

        $response = $enterprise.loader.Auth.ExecuteAuthRest(
            "enterprise/get_security_report_data",
            $request,
            [Authentication.SecurityReportResponse]
        ).GetAwaiter().GetResult()

        $asOfRevision = $response.AsOfRevision

        try {
            if ($null -eq $rsaKey -and $response.EnterprisePrivateKey -and -not $response.EnterprisePrivateKey.IsEmpty) {
                $keyData = [KeeperSecurity.Utils.CryptoUtils]::DecryptAesV2($response.EnterprisePrivateKey.ToByteArray(), $treeKey)
                $rsaKey = [KeeperSecurity.Utils.CryptoUtils]::LoadRsaPrivateKey($keyData)
            }
            if ($null -eq $ecKey -and $response.EnterpriseEccPrivateKey -and -not $response.EnterpriseEccPrivateKey.IsEmpty) {
                $keyData = [KeeperSecurity.Utils.CryptoUtils]::DecryptAesV2($response.EnterpriseEccPrivateKey.ToByteArray(), $treeKey)
                $ecKey = [KeeperSecurity.Utils.CryptoUtils]::LoadEcPrivateKey($keyData)
            }
        }
        catch { Write-Verbose "Failed to load enterprise private keys from response: $_" }

        foreach ($securityReport in $response.SecurityReport) {
            $user = $null
            if (-not $enterprise.enterpriseData.TryGetUserById($securityReport.EnterpriseUserId, [ref]$user)) {
                continue
            }

            if ($nodeFilter -and -not $nodeFilter.Contains($user.ParentNodeId)) {
                continue
            }

            $email = if ($user.Email) { $user.Email } else { $securityReport.EnterpriseUserId.ToString() }
            $name = if ($user.DisplayName) { $user.DisplayName } else { $email }
            $nodePath = Get-KeeperNodePath -NodeId $user.ParentNodeId
            $twoFactorOn = ($securityReport.TwoFactor -ne 'two_factor_disabled' -and -not [string]::IsNullOrEmpty($securityReport.TwoFactor))

            $row = [ordered]@{
                email = $email
                name = $name
                sync_pending = ''
                node = $nodePath
                reused = [int]$securityReport.NumberOfReusedPassword
                twoFactorChannel = if ($twoFactorOn) { 'On' } else { 'Off' }
            }

            if ($securityReport.EncryptedReportData -and $securityReport.EncryptedReportData.Length -gt 0) {
                try {
                    $decryptedBytes = [KeeperSecurity.Utils.CryptoUtils]::DecryptAesV2($securityReport.EncryptedReportData.ToByteArray(), $treeKey)
                    $json = [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
                    $data = Expand-KeeperSecurityAuditData -Json $json -NumberOfReusedPasswords $securityReport.NumberOfReusedPassword
                }
                catch {
                    $invalidUsers.Add($securityReport.EnterpriseUserId) | Out-Null
                    continue
                }
            }
            else {
                $data = @{
                    weak_record_passwords = 0
                    fair_record_passwords = 0
                    medium_record_passwords = 0
                    strong_record_passwords = 0
                    total_record_passwords = 0
                    unique_record_passwords = 0
                    passed_records = 0
                    at_risk_records = 0
                    ignored_records = 0
                }
            }

            $rowIncrementalFailed = $false
            if ($showUpdatedData -and $securityReport.SecurityReportIncrementalData.Count -gt 0) {
                $data = Update-KeeperSecurityIncrementalData -Data $data -SecurityReport $securityReport -RsaKey $rsaKey -EcKey $ecKey -HasErrors ([ref]$rowIncrementalFailed)
                if ($rowIncrementalFailed) {
                    $hasErrors = $true
                }
                else {
                    $data.unique_record_passwords = [Math]::Max(0, (ConvertTo-KeeperInt $data.total_record_passwords) - [int]$securityReport.NumberOfReusedPassword)
                }
            }

            $row.weak = ConvertTo-KeeperInt $data.weak_record_passwords
            $row.fair = ConvertTo-KeeperInt $data.fair_record_passwords
            $row.medium = ConvertTo-KeeperInt $data.medium_record_passwords
            $row.strong = ConvertTo-KeeperInt $data.strong_record_passwords
            $row.total = ConvertTo-KeeperInt $data.total_record_passwords
            $row.unique = ConvertTo-KeeperInt $data.unique_record_passwords
            $row.passed = ConvertTo-KeeperInt $data.passed_records
            $row.at_risk = ConvertTo-KeeperInt $data.at_risk_records
            $row.ignored = ConvertTo-KeeperInt $data.ignored_records

            if ($row.unique -lt 0 -and $row.total -gt 0 -and $AttemptFix.IsPresent) {
                $invalidUsers.Add($securityReport.EnterpriseUserId) | Out-Null
                continue
            }

            if ($rowIncrementalFailed) {
                $row.sync_pending = 'Error'
            }
            elseif ($row.total -eq 0 -and $row.reused -ne 0) {
                $row.sync_pending = 'Yes'
            }

            if ($useStrongPasswordScoring) {
                $score = Get-KeeperStrongByTotal -Total $row.total -Strong $row.strong
                $displayScore = [int](100 * $score)
            }
            else {
                $score = Get-KeeperSecurityScore -Total $row.total -Strong $row.strong -Unique $row.unique -TwoFactorOn $twoFactorOn
                $displayScore = [int](100 * [Math]::Round($score, 2))
            }
            $row.securityScore = $displayScore

            $rows.Add([PSCustomObject]$row) | Out-Null

            if ($saveReport -and -not $hasErrors) {
                try {
                    $updatedSecurityReport = New-Object Authentication.SecurityReport
                    $updatedSecurityReport.Revision = $asOfRevision
                    $updatedSecurityReport.EnterpriseUserId = $securityReport.EnterpriseUserId
                    $reportJson = Format-KeeperSecurityReportData -Data $data
                    $jsonBytes = [System.Text.Encoding]::UTF8.GetBytes($reportJson)
                    $updatedSecurityReport.EncryptedReportData = [Google.Protobuf.ByteString]::CopyFrom(
                        [KeeperSecurity.Utils.CryptoUtils]::EncryptAesV2($jsonBytes, $treeKey)
                    )
                    $updatedSecurityReports.Add($updatedSecurityReport) | Out-Null
                }
                catch {
                    $saveBuildFailures++
                }
            }
        }

        $complete = $response.Complete
        $fromPage = $response.ToPage + 1
    }

    if ($invalidUsers.Count -gt 0) {
        Write-Warning "Decryption failed for $($invalidUsers.Count) user(s). Successfully decrypted: $($rows.Count)."
    }
    else {
        Write-Verbose "All $($rows.Count) user record(s) decrypted successfully."
    }

    if ($AttemptFix.IsPresent -and $invalidUsers.Count -gt 0) {
        if (Confirm-KeeperSecurityAuditAction -Prompt "Do you want to reset their security data? (y/n)" -Force:$Force.IsPresent) {
            Clear-KeeperSecurityDataForUsers -Enterprise $enterprise -UserIds $invalidUsers
        }
        else {
            Write-Host "Skipping security data reset."
        }
    }

    if ($saveReport -and $saveBuildFailures -gt 0) {
        Write-Warning "Unable to prepare $saveBuildFailures updated security report(s). Save skipped."
    }
    elseif ($saveReport -and $hasErrors) {
        Write-Warning "Updated security scores were not saved because some incremental security data could not be processed."
    }
    elseif ($saveReport -and $updatedSecurityReports.Count -gt 0) {
        if (Confirm-KeeperSecurityAuditAction -Prompt "Push updated security scores to Keeper? (y/n)" -Force:$Force.IsPresent) {
            try {
                $saveRequest = New-Object Authentication.SecurityReportSaveRequest
                $saveRequest.SecurityReport.AddRange($updatedSecurityReports)
                $enterprise.loader.Auth.ExecuteAuthRest("enterprise/save_summary_security_report", $saveRequest).GetAwaiter().GetResult() | Out-Null
                Write-Host "Security scores pushed to Keeper."
            }
            catch {
                Write-Warning "Error saving security reports: $($_.Exception.Message)"
            }
        }
        else {
            Write-Host "Save cancelled."
        }
    }

    $sortedRows = @($rows | Sort-Object email)
    Write-KeeperSecurityAuditOutput -Rows $sortedRows -ShowBreachWatch:$BreachWatch.IsPresent -Format $Format -Output $Output
}

function Get-KeeperBreachWatchReport {
    <#
    .SYNOPSIS
    Run a BreachWatch security report for all users in your enterprise.

    .DESCRIPTION
    Validates BreachWatch reporting access, then executes the security audit report
    pipeline in BreachWatch mode with updated report data saved back to Keeper.
    Note: this command pushes updated summary scores to Keeper automatically.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('table', 'json', 'csv')]
        [string] $Format = 'table',

        [Parameter()]
        [string] $Output
    )

    [Enterprise]$enterprise = getEnterprise
    if (-not (Test-KeeperRunReportsPrivilege -Enterprise $enterprise)) {
        throw "You do not have the required privilege to run a BreachWatch report"
    }

    if (-not (Test-KeeperEnterpriseAddonEnabled -Enterprise $enterprise -AddonName 'enterprise_breach_watch')) {
        throw "BreachWatch is not enabled for this enterprise."
    }

    Get-KeeperSecurityAuditReport -Format $Format -Output $Output -BreachWatch -Save -Force
}

Set-Alias -Name bw-report -Value Get-KeeperBreachWatchReport

# SIG # Begin signature block
# MIInvgYJKoZIhvcNAQcCoIInrzCCJ6sCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC/aYDGwNG2lW7L
# lqeN6BxJmprsKp4+5MmcZXQ3fi4UAKCCITswggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3
# zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch
# TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj
# FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo
# yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP
# KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS
# uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w
# JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW
# doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg
# rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K
# 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf
# gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy
# Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ
# D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/
# ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu
# +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o
# bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h
# ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn
# M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol
# /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY
# xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc
# CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB
# ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB0kwggUx
# oAMCAQICEAe0P3SLJmcoVNrErUyxTt0wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNTEyMzEwMDAwMDBaFw0yOTAxMDIyMzU5NTlaMIHRMRMwEQYLKwYBBAGC
# NzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMR0wGwYDVQQPDBRQ
# cml2YXRlIE9yZ2FuaXphdGlvbjEQMA4GA1UEBRMHMzQwNzk4NTELMAkGA1UEBhMC
# VVMxETAPBgNVBAgTCElsbGlub2lzMRAwDgYDVQQHEwdDaGljYWdvMR0wGwYDVQQK
# ExRLZWVwZXIgU2VjdXJpdHkgSW5jLjEdMBsGA1UEAxMUS2VlcGVyIFNlY3VyaXR5
# IEluYy4wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCUcNMoSVmxAi0a
# vG+StFJMNFFTUIOo3HdBZ+0gqA1XpNgUx11vB1vCZrvFsD9m5oA58tdp4gZN3LmQ
# aMvCl2ANUT7MilI02Hf1RWlygBzon6iE0GpU3lgRrwrk1dhtLpGsR6dbMKUUHprc
# vKpXk90/VN+vhzY1uik1tCTxkDCPu/AYJg7m9+tR2KqvMuYMaMLhii66eWUAGsBC
# h/uZxjkGoJF6qZ0DgFd7rW7VYljbfYSNPeZNGTDgB0J/wOsKl0mn612DTseIvAKt
# 4vra/FLFukyEyStnfQ8lWYDcLLCMCjNVrzGipmT5E2iyx7Y1RZCIpNwVogp3Ixbk
# Gbq5A/41YNOLLd4cFewyB2F037RevBCRsUODZEt1qBf7Jbu3DiYo1G+zTj9E0R1s
# FzyijcfdsTm6X5ble+yCJeGkX5XgsyPnZpyz/FX9Fr0N9pMPGWwW2PKyHEnSytXm
# 0Dxdq2P4mA4CBUxq7YoV26L2PF6QEh9BQdXTPcnLysUv7SI/a0ECAwEAAaOCAgIw
# ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBRG
# 4H6CH8pvNX632bsdnrda4MtJLDA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB
# BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC
# B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p
# bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
# QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT
# QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA
# A4ICAQA1Wlq0WzJa3N6DgjgBU7nagIJBab1prPARXZreX1MOv9VjnS5o0CrfQLr6
# z3bmWHw7xT8dt6bcSwRixqvPJtv4q8Rvo80O3eUMvMxQzqmi7z1zf+HG+/3G4F+2
# IYegvPc8Ui151XCV9rjA8tvFWRLRMX0ZRxY1zfT027HMw0iYL20z44+Cky//FAnL
# iRwoNDGiRkZiHbB9YOftPAYNMG3gm1z3zOW5RdfKPrqvMuijE+dfyLIAA6Immpzu
# FMH+Wgn8NnSlot9b4YKycaqqdjd7wXDjPub/oQ7VShuCSBWj+UNOTVh0vcZGackc
# H1DLVgwp2dcKlxJiQKtkHT/T6LloY6LTe6+8wkVkr8EAv1W+q/+M1a4Ao+ykFbIA
# 2LBEmA9qdgoLtenAYIiEg+48SjMPgyBbVPE3bhL1vIqjEIxYCfdmi6wx33oYX7HB
# +bJ7zitHw4GgtpfPV8y8QRZImKmeDOKyXjQPDmQM/Eglm/Ns0GzBkVXM8h6UI34b
# WZrHz9sbLSE20m5Svmxftvw5zju+I3WsmS/stNfWlOkwU0niUgwPHaz21kjXEA5A
# g+aqv26wodqZcnGOlChoWDvSJ8KKgdOFbeAYKAMp1NY7iWV315zpGH19RipCR1NH
# 0ND8iIubk3WGNf2rzEfqlOi3h2ywqVkU6AKXHdO5JV4otSKKEDGCBdkwggXVAgEB
# MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD
# VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI
# QTM4NCAyMDIxIENBMQIQB7Q/dIsmZyhU2sStTLFO3TANBglghkgBZQMEAgEFAKCB
# hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE
# AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ
# BDEiBCA77RouiJzhJTCktDFNkzcsauI57kMeBqniVTkGDbbf5DANBgkqhkiG9w0B
# AQEFAASCAYBvxRJaiXPid8HcH2YkZ8Lca+CMTPaUcdrzNT8QH25p0kc4ktcGVp6w
# mbzSPW2wbBHReJ4SFrZF0qCJC5A9YG++c6HDqVvm7hpP9AKXewXJbqISOwhg5ehO
# 8xCYqGcwZjcWudj/4CJP2dno8tKJNPiWApisR7dLUHvTw6uWaF2xUSOmdz+sjIeN
# 3wMDj7a24torNz0Eoi6wZwa3lHVK0eChSL7KYM9pO8+q9ORqOSV8pZJmbXYFnCu/
# r7h+1uPtXYMrQ9Q8g2Fu7mKnWs4mLDxliWq8kKYdz0oEeZYKwMUko13aFGF0sdYF
# 7qW9aPnT4wpXxZ7De7UkIe76rPRu2diPvZ+ab8haC13XfA1Fp9aBVw88Ai926dCe
# a9K8S5noIHwlY1fl/dFjwT/2pD33KP/dlr5Y6bGrYLra0QD2OD6I8RfzUnS0PaNS
# Jaf/xMaq2MN5dAq8K1dsSCe1yPuzdrUSEy3Bt7WBvFgDv8MPfWDl7vDQoSkolZZc
# YfGIssVt5QShggMmMIIDIgYJKoZIhvcNAQkGMYIDEzCCAw8CAQEwfTBpMQswCQYD
# VQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lD
# ZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUg
# Q0ExAhAKgO8YS43xBYLRxHanlXRoMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN
# AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjYwNTA1MTgxNzA4WjAv
# BgkqhkiG9w0BCQQxIgQgy9hfOSdLm4+tTpphMR4WvYNdVVpSLYZKcPcvh2+YI2gw
# DQYJKoZIhvcNAQEBBQAEggIAhJ52T7kKbihFAQCzlUhamK1VEqLv0ZiZv8jDiaD/
# 0eSLGY5GFeWmNsPx9GSS5jbF7T3OOGY4Nm1tgQuMD8HQelpdlhIq41a+/UqJwmdH
# hHOqpmXtMFTvt18XYTXy7AWE6KRuFWr4nHjSIvg22lREQ8m+zM12SBhw8hTZUNfo
# 5VEjeUljO2TORMuxTYyNnnncREoZojh3nrcV0Rx1QXecJsTM3Amet0nzkLzvwkLY
# 9aZLQLoggAY4ms4qfX/cPFjZxZgAogTZOXbDdB0VjY0l7zwTpsA/QhdvAMquLLFl
# YEwGnlZ3BgFhnEcqdM2FBR7h+P4SaPeBo4FJysX27clslo/OWUfS5Cc1Ti8jcoDU
# /RLN56T0oAzXKNBMSrEHwbIm+8O3LvfyuTLG5t3JnqxYe3ZOe3dBhr3l3/6BAsYt
# 8KKOJ4WOLQT48tOxs45sE3oMObb2m95AM0Ov/2aP3w6IN+dJgS/ErxNn05z+OpmZ
# +t8KHsmqzAhs+/ztXg5bSPf0LAisf7ioFDPd5U0emfXqJ30adpwpUvq+LGyu055w
# M63CO/Mi83QsJnckwyhGeuG2C2125sTzTUl4+Pb8EE3fBHlvKfBt8dftCG2K1djq
# 6V4CuAWCvauoVsPw5OgwuE+xY7Jpwv69ro5+Nni5J90slLogiJWj2c+52igPP9iL
# EJk=
# SIG # End signature block